diff --git a/CHANGELOG.md b/CHANGELOG.md index a6684e2067aa51..7e7f03bcf81a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,54 @@ All notable changes to this project will be documented in this file. +## [4.4.5] - 2025-09-23 + +### Security + +- Update dependencies + +### Added + +- Add support for `has:quote` in search (#36217 by @ClearlyClaire) + +### Changed + +- Change quoted posts from silenced accounts to use a click-through rather than being hidden (#36166 and #36167 by @ClearlyClaire) + +### Fixed + +- Fix processing of out-of-order `Update` as implicit updates (#36190 by @ClearlyClaire) +- Fix getting `Create` and `Update` out of order (#36176 by @ClearlyClaire) +- Fix quotes with Content Warnings but no text being shown without Content Warnings (#36150 by @ClearlyClaire) + +## [4.4.4] - 2025-09-16 + +### Security + +- Update dependencies + +### Fixed + +- Fix missing memoization in `Web::PushNotificationWorker` (#36085 by @ClearlyClaire) +- Fix unresponsive areas around GIFV modals in some cases (#36059 by @ClearlyClaire) +- Fix missing `beforeUnload` confirmation when a poll is being authored (#36030 by @ClearlyClaire) +- Fix processing of remote edited statuses with new media and no text (#35970 by @unfokus) +- Fix polls not being displayed in moderation interface (#35644 and #35933 by @ThisIsMissEm) +- Fix WebUI handling of deleted quoted posts (#35909 and #35918 by @ClearlyClaire and @diondiondion) +- Fix “Edit” and “Delete & Redraft” on a poll not inserting empty option (#35892 by @ClearlyClaire) +- Fix loading of some compatibility CSS on some configurations (#35876 by @shleeable) +- Fix HttpLog not being enabled with `RAILS_LOG_LEVEL=debug` (#35833 by @mjankowski) +- Fix self-destruct scheduler behavior on some Redis setups (#35823 by @ClearlyClaire) +- Fix `tootctl admin create` not bypassing reserved username checks (#35779 by @ClearlyClaire) +- Fix interaction policy changes in implicit updates not being saved (#35751 by @ClearlyClaire) +- Fix quote revocation not being streamed (#35710 by @ClearlyClaire) +- Fix export of large user archives by enabling Zip64 (#35850 by @ClearlyClaire) + +### Changed + +- Change labels for quote policy settings (#35893 by @ClearlyClaire) +- Change standalone “Share” page to redirect to web interface after posting (#35763 by @ChaosExAnima) + ## [4.4.3] - 2025-08-05 ### Security diff --git a/Gemfile.lock b/Gemfile.lock index 0f6f85585c858f..73755eb3b9e71d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -735,7 +735,7 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.4.1) + rexml (3.4.4) rotp (6.3.0) rouge (4.6.0) rpam2 (4.0.2) diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 5bb221df305406..cdb91dc8a8e6a6 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -93,7 +93,7 @@ export function normalizeStatus(status, normalOldStatus, options = undefined) { } else { // If the status has a CW but no contents, treat the CW as if it were the // status' contents, to avoid having a CW toggle with seemingly no effect. - if (normalStatus.spoiler_text && !normalStatus.content) { + if (normalStatus.spoiler_text && !normalStatus.content && !normalStatus.quote) { normalStatus.content = normalStatus.spoiler_text; normalStatus.spoiler_text = ''; } diff --git a/app/javascript/mastodon/components/hover_card_account.tsx b/app/javascript/mastodon/components/hover_card_account.tsx index fe37c213fd0f19..5dbf7e2ed2966e 100644 --- a/app/javascript/mastodon/components/hover_card_account.tsx +++ b/app/javascript/mastodon/components/hover_card_account.tsx @@ -19,7 +19,7 @@ import { FollowButton } from 'mastodon/components/follow_button'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { ShortNumber } from 'mastodon/components/short_number'; import { useFetchFamiliarFollowers } from 'mastodon/features/account_timeline/hooks/familiar_followers'; -import { domain } from 'mastodon/initial_state'; +import { domain, isHideItem } from 'mastodon/initial_state'; import { getAccountHidden } from 'mastodon/selectors/accounts'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; @@ -54,8 +54,10 @@ export const HoverCardAccount = forwardRef< const relationship = useAppSelector((state) => accountId ? state.relationships.get(accountId) : undefined, ); - const isMutual = relationship?.followed_by && relationship.following; - const isFollower = relationship?.followed_by; + const isHideRelationships = isHideItem('relationships'); + const isMutual = + !isHideRelationships && relationship?.followed_by && relationship.following; + const isFollower = !isHideRelationships && relationship?.followed_by; const hasRelationshipLoaded = !!relationship; const shouldDisplayFamiliarFollowers = diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index 1cecdd7028ec9c..badf94517b9412 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -50,7 +50,12 @@ import { DomainPill } from 'mastodon/features/account/components/domain_pill'; import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container'; import { useLinks } from 'mastodon/hooks/useLinks'; import { useIdentity } from 'mastodon/identity_context'; -import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state'; +import { + autoPlayGif, + me, + domain as localDomain, + isHideItem, +} from 'mastodon/initial_state'; import type { Account } from 'mastodon/models/account'; import type { MenuItem } from 'mastodon/models/dropdown_menu'; import { @@ -220,6 +225,8 @@ export const AccountHeader: React.FC<{ const hidden = useAppSelector((state) => getAccountHidden(state, accountId)); const handleLinkClick = useLinks(); + const isHideRelationships = isHideItem('relationships'); + const handleBlock = useCallback(() => { if (!account) { return; @@ -548,7 +555,7 @@ export const AccountHeader: React.FC<{ text: intl.formatMessage(messages.add_or_remove_from_exclude_antenna), action: handleAddToExcludeAntenna, }); - if (relationship?.followed_by) { + if (!isHideRelationships && relationship?.followed_by) { arr.push({ text: intl.formatMessage(messages.add_or_remove_from_circle), action: handleAddToCircle, @@ -556,7 +563,7 @@ export const AccountHeader: React.FC<{ } arr.push(null); - if (relationship?.followed_by) { + if (!isHideRelationships && relationship?.followed_by) { const handleRemoveFromFollowers = () => { dispatch( openModal({ @@ -709,6 +716,7 @@ export const AccountHeader: React.FC<{ handleReblogToggle, handleReport, handleUnblockDomain, + isHideRelationships, ]); if (!account) { @@ -724,6 +732,7 @@ export const AccountHeader: React.FC<{ if (me !== account.id && relationship) { if ( + !isHideRelationships && relationship.followed_by && (relationship.following || relationship.requested) ) { @@ -735,7 +744,7 @@ export const AccountHeader: React.FC<{ /> , ); - } else if (relationship.followed_by) { + } else if (!isHideRelationships && relationship.followed_by) { info.push( , ); - } else if (relationship.requested_by) { + } else if (!relationship.followed_by && relationship.requested_by) { info.push( = ({ disabled = false }) => { const intl = useIntl(); - const { visibility, searchability, quotePolicy, circleId } = useAppSelector( - (state) => ({ - visibility: state.compose.get('privacy') as StatusVisibility, - searchability: state.compose.get('searchability') as StatusSearchability, - quotePolicy: state.compose.get('quote_policy') as ApiQuotePolicy, - circleId: state.compose.get('circle_id') as string, - }), + const quotePolicy = useAppSelector( + (state) => state.compose.get('quote_policy') as ApiQuotePolicy, + ); + const visibility = useAppSelector( + (state) => state.compose.get('privacy') as StatusVisibility, + ); + const searchability = useAppSelector( + (state) => state.compose.get('searchability') as StatusSearchability, + ); + const circleId = useAppSelector( + (state) => state.compose.get('circle_id') as string, ); const { icon, iconComponent } = useMemo(() => { diff --git a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js index 786d63cea96295..c3e95054e13307 100644 --- a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js +++ b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js @@ -37,13 +37,9 @@ const DEFAULTS = [ const RECENT_SIZE = DEFAULTS.length; const getFrequentlyUsedEmojis = createSelector([ - state => { return { - emojiCounters: state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()), - reactionDeck: state.get('reaction_deck', ImmutableList()), - }; }, -], data => { - const { emojiCounters, reactionDeck } = data; - + state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()), + state => state.get('reaction_deck', ImmutableList()), +], (emojiCounters, reactionDeck) => { let deckEmojis = reactionDeck .toArray() .map((e) => e.get('name')) diff --git a/app/javascript/mastodon/features/emoji/emoji_html.tsx b/app/javascript/mastodon/features/emoji/emoji_html.tsx index 0bd10009226d4d..e143c9fc166f8b 100644 --- a/app/javascript/mastodon/features/emoji/emoji_html.tsx +++ b/app/javascript/mastodon/features/emoji/emoji_html.tsx @@ -1,5 +1,7 @@ import type { ComponentPropsWithoutRef, ElementType } from 'react'; +import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; + import { useEmojify } from './hooks'; import type { CustomEmojiMapArg } from './types'; @@ -13,7 +15,7 @@ type EmojiHTMLProps = Omit< shallow?: boolean; }; -export const EmojiHTML = ({ +export const ModernEmojiHTML = ({ extraEmojis, htmlString, as: Wrapper = 'div', // Rename for syntax highlighting @@ -34,3 +36,14 @@ export const EmojiHTML = ({ ); }; + +export const EmojiHTML = ( + props: EmojiHTMLProps, +) => { + if (isModernEmojiEnabled()) { + return ; + } + const { as: asElement, htmlString, extraEmojis, ...rest } = props; + const Wrapper = asElement ?? 'div'; + return ; +}; diff --git a/app/javascript/mastodon/features/navigation_panel/components/antenna_panel.tsx b/app/javascript/mastodon/features/navigation_panel/components/antenna_panel.tsx index a2fa9433157fec..ad06554bd5bfbb 100644 --- a/app/javascript/mastodon/features/navigation_panel/components/antenna_panel.tsx +++ b/app/javascript/mastodon/features/navigation_panel/components/antenna_panel.tsx @@ -5,7 +5,7 @@ import { useIntl, defineMessages } from 'react-intl'; import { fetchAntennas } from '@/mastodon/actions/antennas_typed'; import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react'; import { ColumnLink } from 'mastodon/features/ui/components/column_link'; -import { getOrderedAntennas } from 'mastodon/selectors/antennas'; +import { getFavouritedAntennas } from 'mastodon/selectors/antennas'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { CollapsiblePanel } from './collapsible_panel'; @@ -25,7 +25,7 @@ const messages = defineMessages({ export const AntennaPanel: React.FC = () => { const intl = useIntl(); const dispatch = useAppDispatch(); - const antennas = useAppSelector((state) => getOrderedAntennas(state)); + const antennas = useAppSelector((state) => getFavouritedAntennas(state)); const [loading, setLoading] = useState(false); useEffect(() => { diff --git a/app/javascript/mastodon/features/navigation_panel/components/list_panel.tsx b/app/javascript/mastodon/features/navigation_panel/components/list_panel.tsx index 020abf36cfb8db..fda2ec92351548 100644 --- a/app/javascript/mastodon/features/navigation_panel/components/list_panel.tsx +++ b/app/javascript/mastodon/features/navigation_panel/components/list_panel.tsx @@ -6,7 +6,7 @@ import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import { fetchLists } from 'mastodon/actions/lists'; import { ColumnLink } from 'mastodon/features/ui/components/column_link'; -import { getOrderedLists } from 'mastodon/selectors/lists'; +import { getFavouritedLists } from 'mastodon/selectors/lists'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { CollapsiblePanel } from './collapsible_panel'; @@ -26,7 +26,7 @@ const messages = defineMessages({ export const ListPanel: React.FC = () => { const intl = useIntl(); const dispatch = useAppDispatch(); - const lists = useAppSelector((state) => getOrderedLists(state)); + const lists = useAppSelector((state) => getFavouritedLists(state)); const [loading, setLoading] = useState(false); useEffect(() => { diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 5cfddfde683210..6ea9bb065e60e4 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -95,7 +95,7 @@ const makeMapStateToProps = () => { if (status) { ancestorsIds = getAncestorsIds(state, status.get('in_reply_to_id')); descendantsIds = getDescendantsIds(state, status.get('id')); - referencesIds = getReferencesIds(state, status.get('id')); + referencesIds = getReferencesIds(state, status.get('id'), status.getIn(['quote', 'quoted_status'])); } return { diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index d10bb780905e27..06b38e2a3def91 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -55,6 +55,7 @@ "account.followers": "フォロワー", "account.followers.empty": "まだ誰もフォローしていません。", "account.followers_counter": "{count, plural, other {{counter} フォロワー}}", + "account.followers.hidden_from_me": "この情報はあなた自身の設定によって隠されています。", "account.followers_you_know_counter": "あなたと知り合いの{counter}人", "account.following": "フォロー中", "account.following_counter": "{count, plural, other {{counter} フォロー}}", diff --git a/app/javascript/mastodon/selectors/antennas.ts b/app/javascript/mastodon/selectors/antennas.ts index daa19f7508f158..6e611a471ee73f 100644 --- a/app/javascript/mastodon/selectors/antennas.ts +++ b/app/javascript/mastodon/selectors/antennas.ts @@ -16,3 +16,12 @@ export const getOrderedAntennas = createAppSelector( .sort((a: Antenna, b: Antenna) => a.title.localeCompare(b.title)) .toArray(), ); + +export const getFavouritedAntennas = createAppSelector( + [(state) => getAntennas(state)], + (antennas) => + antennas + .sort((a: Antenna, b: Antenna) => a.title.localeCompare(b.title)) + .filter((antenna) => antenna.favourite) + .toArray(), +); diff --git a/app/javascript/mastodon/selectors/contexts.ts b/app/javascript/mastodon/selectors/contexts.ts index e17d7e472220d4..f6d38f922e7024 100644 --- a/app/javascript/mastodon/selectors/contexts.ts +++ b/app/javascript/mastodon/selectors/contexts.ts @@ -94,8 +94,12 @@ export const getDescendantsIds = createAppSelector( ); export const getReferencesIds = createAppSelector( - [(_, id: string) => id, (state) => state.contexts.references], - (statusId, references) => { - return references[statusId] ?? []; + [ + (_, id: string) => id, + (state) => state.contexts.references, + (_, __, exceptId: string) => exceptId, + ], + (statusId, references, exceptId) => { + return references[statusId]?.filter((id) => exceptId !== id) ?? []; }, ); diff --git a/app/javascript/mastodon/selectors/lists.ts b/app/javascript/mastodon/selectors/lists.ts index 9b79a880a9c671..80d5c090623752 100644 --- a/app/javascript/mastodon/selectors/lists.ts +++ b/app/javascript/mastodon/selectors/lists.ts @@ -14,3 +14,12 @@ export const getOrderedLists = createAppSelector( (lists) => lists.sort((a: List, b: List) => a.title.localeCompare(b.title)).toArray(), ); + +export const getFavouritedLists = createAppSelector( + [(state) => getLists(state)], + (lists) => + lists + .sort((a: List, b: List) => a.title.localeCompare(b.title)) + .filter((list) => list.favourite) + .toArray(), +); diff --git a/app/lib/status_cache_hydrator.rb b/app/lib/status_cache_hydrator.rb index 5ddd10e8e2d4cd..ae56ff1105f7d7 100644 --- a/app/lib/status_cache_hydrator.rb +++ b/app/lib/status_cache_hydrator.rb @@ -100,7 +100,7 @@ def hydrate_quote_payload(empty_payload, quote, account_id, nested: false) if quote.quoted_status.nil? payload[nested ? :quoted_status_id : :quoted_status] = nil payload[:state] = 'deleted' - elsif StatusFilter.new(quote.quoted_status, Account.find_by(id: account_id)).filtered? + elsif StatusFilter.new(quote.quoted_status, Account.find_by(id: account_id)).filtered_for_quote? payload[nested ? :quoted_status_id : :quoted_status] = nil payload[:state] = 'unauthorized' else diff --git a/app/lib/status_filter.rb b/app/lib/status_filter.rb index 8df80b7d63c4aa..3400b202b7fa5b 100644 --- a/app/lib/status_filter.rb +++ b/app/lib/status_filter.rb @@ -16,6 +16,12 @@ def filtered? blocked_by_policy? || (account_present? && filtered_status?) || silenced_account? end + def filtered_for_quote? + return false if !account.nil? && account.id == status.account_id + + blocked_by_policy? || (account_present? && filtered_status?) + end + private def account_present? diff --git a/app/models/concerns/status/search_concern.rb b/app/models/concerns/status/search_concern.rb index 65422d55abb946..705cbcc27a144e 100644 --- a/app/models/concerns/status/search_concern.rb +++ b/app/models/concerns/status/search_concern.rb @@ -81,6 +81,7 @@ def searchable_properties properties << 'sensitive' if sensitive? properties << 'reply' if reply? properties << 'reference' if with_status_reference? + properties << 'quote' if with_quote? end end end diff --git a/app/serializers/rest/base_quote_serializer.rb b/app/serializers/rest/base_quote_serializer.rb index 20a53d1a20e100..be9d5cbe6f238e 100644 --- a/app/serializers/rest/base_quote_serializer.rb +++ b/app/serializers/rest/base_quote_serializer.rb @@ -8,13 +8,13 @@ def state # Extra states when a status is unavailable return 'deleted' if object.quoted_status.nil? - return 'unauthorized' if status_filter.filtered? + return 'unauthorized' if status_filter.filtered_for_quote? object.state end def quoted_status - object.quoted_status if object.accepted? && object.quoted_status.present? && !status_filter.filtered? + object.quoted_status if object.accepted? && object.quoted_status.present? && !status_filter.filtered_for_quote? end private diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index a8381fa4196e86..f2ad8fe75c9733 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -31,6 +31,9 @@ def call(status, activity_json, object_json, request_id: nil) return @status unless valid_status? handle_explicit_update! + elsif @status.edited_at.present? && (@status_parser.edited_at.nil? || @status_parser.edited_at < @status.edited_at) + # This is an older update, reject it + return @status else handle_implicit_update! end diff --git a/docker-compose.yml b/docker-compose.yml index 112348bb757fe7..2ccd5b89ae3e61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,7 @@ services: web: # 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: kmyblue:20.1 + image: kmyblue:20.2-dev restart: always env_file: .env.production command: bundle exec puma -C config/puma.rb @@ -83,7 +83,7 @@ services: build: dockerfile: ./streaming/Dockerfile context: . - image: kmyblue-streaming:20.1 + image: kmyblue-streaming:20.2-dev restart: always env_file: .env.production command: node ./streaming/index.js @@ -101,7 +101,7 @@ services: sidekiq: build: . - image: kmyblue:20.1 + image: kmyblue:20.2-dev restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index c4d3998ae31b01..eb039ae6f45619 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ def kmyblue_major end def kmyblue_minor - 1 + 2 end def kmyblue_flag