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