Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
29ae9c9
Add 4.5.x to the list of supported branches (#36761)
ClearlyClaire Nov 6, 2025
fbe05d4
Fix prepared quote not being discarded with contents when replying (#…
ClearlyClaire Nov 7, 2025
ea663cf
Fix `/api/v1/statuses/:id/context` sometimes returing `Mastodon-Async…
ClearlyClaire Nov 7, 2025
a9a7ad6
Update dependency rollup from 4.46.2 to 4.46.4 (#36781)
ClearlyClaire Nov 7, 2025
30103fd
Fix dropdown menu not focusing first item when opened via keyboard (#…
diondiondion Nov 10, 2025
9eea447
Fix scroll shift caused by fetch-all-replies alerts (#36807)
diondiondion Nov 10, 2025
9ae0464
Emoji: Load emoji with hash in URL (#36808)
ChaosExAnima Nov 11, 2025
8a100d8
Fix filters not being applied to quotes in detailed view (#36843)
ClearlyClaire Nov 12, 2025
fa2cc40
Fixes blank screen in browsers that don't support `Intl.DisplayNames`…
diondiondion Nov 12, 2025
28b9e90
Fix deprecation warning in Vite (#36849)
ChaosExAnima Nov 12, 2025
59f0134
Fix `Update` importing old previously-unknown activities and treating…
ClearlyClaire Nov 12, 2025
55b9d21
Fix posts coming from public/hashtag streaming being marked as unquot…
ClearlyClaire Nov 13, 2025
e742eff
New Crowdin Translations for stable-4.5 (automated) (#36864)
github-actions[bot] Nov 13, 2025
6baa8f2
Fix Cmd/Ctrl + Enter not submitting Alt text modal on some browsers (…
diondiondion Nov 13, 2025
058f704
Fix error when sending new posts (#36869)
ClearlyClaire Nov 13, 2025
bb6093c
Bump version to v4.5.1
ClearlyClaire Nov 13, 2025
1c62217
Merge remote-tracking branch 'parent/stable-4.5' into kb-draft-21.1-lts
kmycode Nov 13, 2025
fd07543
Bump version to 21.1-lts
kmycode Nov 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

All notable changes to this project will be documented in this file.

## [4.5.1] - 2025-11-13

### Fixes

- Fix Cmd/Ctrl + Enter not submitting Alt text modal on some browsers (#36866 by @diondiondion)
- Fix posts coming from public/hashtag streaming being marked as unquotable (#36860 and #36869 by @ClearlyClaire)
- Fix old previously-undiscovered posts being treated as new when receiving an `Update` (#36848 by @ClearlyClaire)
- Fix blank screen in browsers that don't support `Intl.DisplayNames` (#36847 by @diondiondion)
- Fix filters not being applied to quotes in detailed view (#36843 by @ClearlyClaire)
- Fix scroll shift caused by fetch-all-replies alerts (#36807 by @diondiondion)
- Fix dropdown menu not focusing first item when opened via keyboard (#36804 by @diondiondion)
- Fix assets build issue on arch64 (#36781 by @ClearlyClaire)
- Fix `/api/v1/statuses/:id/context` sometimes returing `Mastodon-Async-Refresh` without `result_count` (#36779 by @ClearlyClaire)
- Fix prepared quote not being discarded with contents when replying (#36778 by @ClearlyClaire)

## [4.5.0] - 2025-11-06

### Added
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/statuses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def context
if async_refresh.running?
add_async_refresh_header(async_refresh)
elsif !current_account.nil? && @status.should_fetch_replies?
add_async_refresh_header(AsyncRefresh.create(refresh_key))
add_async_refresh_header(AsyncRefresh.create(refresh_key, count_results: true))

WorkerBatch.new.within do |batch|
batch.connect(refresh_key, threshold: 1.0)
Expand Down
4 changes: 2 additions & 2 deletions app/javascript/mastodon/actions/importer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export function importFetchedAccounts(accounts) {
return importAccounts({ accounts: normalAccounts });
}

export function importFetchedStatus(status) {
return importFetchedStatuses([status]);
export function importFetchedStatus(status, options = {}) {
return importFetchedStatuses([status], options);
}

export function importFetchedStatuses(statuses) {
Expand Down
9 changes: 7 additions & 2 deletions app/javascript/mastodon/actions/importer/normalizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ function stripQuoteFallback(text) {
return wrapper.innerHTML;
}

export function normalizeStatus(status, normalOldStatus, options = undefined) {
export function normalizeStatus(status, normalOldStatus, { bogusQuotePolicy = false, withoutEmojiReaction = false }) {
const normalStatus = { ...status };

if (bogusQuotePolicy)
normalStatus.quote_approval = null;

normalStatus.account = status.account.id;

if (status.reblog && status.reblog.id) {
Expand Down Expand Up @@ -63,7 +66,7 @@ export function normalizeStatus(status, normalOldStatus, options = undefined) {
}

if (status.emoji_reactions) {
if (!options?.withoutEmojiReaction) {
if (!withoutEmojiReaction) {
normalStatus.emoji_reactions = normalizeEmojiReactions(status.emoji_reactions);
} else {
normalStatus.emoji_reactions = normalOldStatus?.get('emoji_reactions') ?? [];
Expand Down Expand Up @@ -125,6 +128,8 @@ export function normalizeStatus(status, normalOldStatus, options = undefined) {
}

if (normalOldStatus) {
normalStatus.quote_approval ||= normalOldStatus.quote_approval;

const list = normalOldStatus.get('media_attachments');
if (normalStatus.media_attachments && list) {
normalStatus.media_attachments.forEach(item => {
Expand Down
4 changes: 2 additions & 2 deletions app/javascript/mastodon/actions/statuses.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,8 @@ export function deleteStatusFail(id, error) {
};
}

export const updateStatus = status => dispatch =>
dispatch(importFetchedStatus(status));
export const updateStatus = (status, { bogusQuotePolicy }) => dispatch =>
dispatch(importFetchedStatus(status, { bogusQuotePolicy }));

export function muteStatus(id) {
return (dispatch) => {
Expand Down
7 changes: 5 additions & 2 deletions app/javascript/mastodon/actions/streaming.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ const randomUpTo = max =>
export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => {
const { messages } = getLocale();

// Public streams are currently not returning personalized quote policies
const bogusQuotePolicy = channelName.startsWith('public') || channelName.startsWith('hashtag');

return connectStream(channelName, params, (dispatch, getState) => {
// @ts-ignore
const locale = getState().getIn(['meta', 'locale']);
Expand Down Expand Up @@ -98,11 +101,11 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
switch (data.event) {
case 'update':
// @ts-expect-error
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), { accept: options.accept, bogusQuotePolicy }));
break;
case 'status.update':
// @ts-expect-error
dispatch(updateStatus(JSON.parse(data.payload)));
dispatch(updateStatus(JSON.parse(data.payload), { bogusQuotePolicy }));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
Expand Down
4 changes: 2 additions & 2 deletions app/javascript/mastodon/actions/timelines.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const loadPending = timeline => ({
timeline,
});

export function updateTimeline(timeline, status, accept) {
export function updateTimeline(timeline, status, { accept = undefined, bogusQuotePolicy = false } = {}) {
return (dispatch, getState) => {
if (typeof accept === 'function' && !accept(status)) {
return;
Expand All @@ -45,7 +45,7 @@ export function updateTimeline(timeline, status, accept) {
return;
}

dispatch(importFetchedStatus(status));
dispatch(importFetchedStatus(status, { bogusQuotePolicy }));

dispatch({
type: TIMELINE_UPDATE,
Expand Down
93 changes: 42 additions & 51 deletions app/javascript/mastodon/components/dropdown_menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
closeDropdownMenu,
} from 'mastodon/actions/dropdown_menu';
import { openModal, closeModal } from 'mastodon/actions/modal';
import { fetchStatus } from 'mastodon/actions/statuses';
import { CircularProgress } from 'mastodon/components/circular_progress';
import { isUserTouching } from 'mastodon/is_mobile';
import {
Expand All @@ -42,16 +43,10 @@ import { IconButton } from './icon_button';

let id = 0;

export interface RenderItemFnHandlers {
onClick: React.MouseEventHandler;
onKeyUp: React.KeyboardEventHandler;
}

export type RenderItemFn<Item = MenuItem> = (
item: Item,
index: number,
handlers: RenderItemFnHandlers,
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void,
onClick: React.MouseEventHandler,
) => React.ReactNode;

type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
Expand Down Expand Up @@ -101,7 +96,6 @@ export const DropdownMenu = <Item = MenuItem,>({
onItemClick,
}: DropdownMenuProps<Item>) => {
const nodeRef = useRef<HTMLDivElement>(null);
const focusedItemRef = useRef<HTMLElement | null>(null);

useEffect(() => {
const handleDocumentClick = (e: MouseEvent) => {
Expand Down Expand Up @@ -163,8 +157,11 @@ export const DropdownMenu = <Item = MenuItem,>({
document.addEventListener('click', handleDocumentClick, { capture: true });
document.addEventListener('keydown', handleKeyDown, { capture: true });

if (focusedItemRef.current && openedViaKeyboard) {
focusedItemRef.current.focus({ preventScroll: true });
if (openedViaKeyboard) {
const firstMenuItem = nodeRef.current?.querySelector<
HTMLAnchorElement | HTMLButtonElement
>('li:first-child > :is(a, button)');
firstMenuItem?.focus({ preventScroll: true });
}

return () => {
Expand All @@ -175,13 +172,6 @@ export const DropdownMenu = <Item = MenuItem,>({
};
}, [onClose, openedViaKeyboard]);

const handleFocusedItemRef = useCallback(
(c: HTMLAnchorElement | HTMLButtonElement | null) => {
focusedItemRef.current = c as HTMLElement;
},
[],
);

const handleItemClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
const i = Number(e.currentTarget.getAttribute('data-index'));
Expand All @@ -207,15 +197,6 @@ export const DropdownMenu = <Item = MenuItem,>({
[onClose, onItemClick, items],
);

const handleItemKeyUp = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
handleItemClick(e);
}
},
[handleItemClick],
);

const nativeRenderItem = (option: Item, i: number) => {
if (!isMenuItem(option)) {
return null;
Expand All @@ -232,9 +213,7 @@ export const DropdownMenu = <Item = MenuItem,>({
if (isActionItem(option)) {
element = (
<button
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
aria-disabled={disabled}
>
Expand All @@ -248,23 +227,15 @@ export const DropdownMenu = <Item = MenuItem,>({
target={option.target ?? '_target'}
data-method={option.method}
rel='noopener'
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
>
<DropdownMenuItemContent item={option} />
</a>
);
} else {
element = (
<Link
to={option.to}
ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
>
<Link to={option.to} onClick={handleItemClick} data-index={i}>
<DropdownMenuItemContent item={option} />
</Link>
);
Expand Down Expand Up @@ -307,15 +278,7 @@ export const DropdownMenu = <Item = MenuItem,>({
})}
>
{items.map((option, i) =>
renderItemMethod(
option,
i,
{
onClick: handleItemClick,
onKeyUp: handleItemKeyUp,
},
i === 0 ? handleFocusedItemRef : undefined,
),
renderItemMethod(option, i, handleItemClick),
)}
</ul>
)}
Expand All @@ -341,6 +304,7 @@ interface DropdownProps<Item extends object | null = MenuItem> {
*/
scrollKey?: string;
status?: ImmutableMap<string, unknown>;
needsStatusRefresh?: boolean;
forceDropdown?: boolean;
renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<Item>;
Expand All @@ -365,6 +329,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
placement = 'bottom',
offset = [5, 5],
status,
needsStatusRefresh,
forceDropdown = false,
renderItem,
renderHeader,
Expand All @@ -384,6 +349,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
const prefetchAccountId = status
? status.getIn(['account', 'id'])
: undefined;
const statusId = status?.get('id') as string | undefined;

const handleClose = useCallback(() => {
if (buttonRef.current) {
Expand All @@ -401,7 +367,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
}, [dispatch, currentId]);

const handleItemClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
(e: React.MouseEvent) => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = items?.[i];

Expand All @@ -422,10 +388,20 @@ export const Dropdown = <Item extends object | null = MenuItem>({
[handleClose, onItemClick, items],
);

const toggleDropdown = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
const { type } = e;
const isKeypressRef = useRef(false);

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') {
isKeypressRef.current = true;
}
}, []);

const unsetIsKeypress = useCallback(() => {
isKeypressRef.current = false;
}, []);

const toggleDropdown = useCallback(
(e: React.MouseEvent) => {
if (open) {
handleClose();
} else {
Expand All @@ -438,6 +414,15 @@ export const Dropdown = <Item extends object | null = MenuItem>({
dispatch(fetchRelationships([prefetchAccountId]));
}

if (needsStatusRefresh && statusId) {
dispatch(
fetchStatus(statusId, {
forceFetch: true,
alsoFetchContext: false,
}),
);
}

if (isUserTouching() && !forceDropdown) {
dispatch(
openModal({
Expand All @@ -452,10 +437,11 @@ export const Dropdown = <Item extends object | null = MenuItem>({
dispatch(
openDropdownMenu({
id: currentId,
keyboard: type !== 'click',
keyboard: isKeypressRef.current,
scrollKey,
}),
);
isKeypressRef.current = false;
}
}
},
Expand All @@ -470,6 +456,8 @@ export const Dropdown = <Item extends object | null = MenuItem>({
items,
forceDropdown,
handleClose,
statusId,
needsStatusRefresh,
],
);

Expand All @@ -486,6 +474,9 @@ export const Dropdown = <Item extends object | null = MenuItem>({
const buttonProps = {
disabled,
onClick: toggleDropdown,
onKeyDown: handleKeyDown,
onKeyUp: unsetIsKeypress,
onBlur: unsetIsKeypress,
'aria-expanded': open,
'aria-controls': menuId,
ref: buttonRef,
Expand Down
14 changes: 2 additions & 12 deletions app/javascript/mastodon/components/edited_timestamp/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,7 @@ export const EditedTimestamp: React.FC<{
}, []);

const renderItem = useCallback(
(
item: HistoryItem,
index: number,
{
onClick,
onKeyUp,
}: {
onClick: React.MouseEventHandler;
onKeyUp: React.KeyboardEventHandler;
},
) => {
(item: HistoryItem, index: number, onClick: React.MouseEventHandler) => {
const formattedDate = (
<RelativeTimestamp
timestamp={item.get('created_at') as string}
Expand Down Expand Up @@ -98,7 +88,7 @@ export const EditedTimestamp: React.FC<{
className='dropdown-menu__item edited-timestamp__history__item'
key={item.get('created_at') as string}
>
<button data-index={index} onClick={onClick} onKeyUp={onKeyUp}>
<button data-index={index} onClick={onClick} type='button'>
{label}
</button>
</li>
Expand Down
Loading
Loading