diff --git a/CHANGELOG.md b/CHANGELOG.md index 56b6fbc92c..bf5dbd2ba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to this project will be documented in this file. +## [4.5.9] - 2026-04-15 + +### Security + +- Insufficient verification of email addresses ([GHSA-5r37-qpwq-2jhh](https://github.com/mastodon/mastodon/security/advisories/GHSA-5r37-qpwq-2jhh)) +- Updated dependencies + +### Added + +- Add trademark warning to `mastodon:setup` task (#38548 by @ClearlyClaire) + +### Fixed + +- Fix definition for `quote` in JSON-LD context (#38686 by @ClearlyClaire) +- Fix being unable to disable sound for quote update notification (#38537 by @ClearlyClaire) +- Fix being able to quote someone you blocked (#38608 by @ClearlyClaire) + ## [4.5.8] - 2026-03-24 ### Security diff --git a/README.md b/README.md index 3bf5968b64..e4b25b1716 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Mastodon Glitch Edition is a fork of [Mastodon](https://github.com/mastodon/mast

- Mastodon hero image + Mastodon hero image

diff --git a/app/javascript/images/elephant_ui.svg b/app/javascript/images/elephant_ui.svg new file mode 100644 index 0000000000..88b23841ff --- /dev/null +++ b/app/javascript/images/elephant_ui.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/images/elephant_ui_dark.svg b/app/javascript/images/elephant_ui_dark.svg deleted file mode 100644 index aa91b704c7..0000000000 --- a/app/javascript/images/elephant_ui_dark.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/javascript/images/elephant_ui_light.svg b/app/javascript/images/elephant_ui_light.svg deleted file mode 100644 index 1eeab31cf4..0000000000 --- a/app/javascript/images/elephant_ui_light.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/javascript/mastodon/components/account/index.tsx b/app/javascript/mastodon/components/account/index.tsx index d93415f15a..9ec2e13205 100644 --- a/app/javascript/mastodon/components/account/index.tsx +++ b/app/javascript/mastodon/components/account/index.tsx @@ -277,7 +277,7 @@ export const Account: React.FC = ({ if (account?.mute_expires_at) { muteTimeRemaining = ( <> - · + · ); } diff --git a/app/javascript/mastodon/components/account_list_item/index.tsx b/app/javascript/mastodon/components/account_list_item/index.tsx index 647adbfa79..34ae8b0daf 100644 --- a/app/javascript/mastodon/components/account_list_item/index.tsx +++ b/app/javascript/mastodon/components/account_list_item/index.tsx @@ -149,11 +149,7 @@ export const AccountListItem: React.FC = ({ } > {account.last_status_at ? ( - + ) : ( '-' )} diff --git a/app/javascript/mastodon/components/badge/index.tsx b/app/javascript/mastodon/components/badge/index.tsx index f75d50ebe9..1cc4b531c7 100644 --- a/app/javascript/mastodon/components/badge/index.tsx +++ b/app/javascript/mastodon/components/badge/index.tsx @@ -52,8 +52,10 @@ export const Badge: FC = ({ data-account-role-id={roleId} > {icon} - {label} - {domain && {domain}} + + {label} + {domain && {domain}} + ); diff --git a/app/javascript/mastodon/components/badge/styles.module.scss b/app/javascript/mastodon/components/badge/styles.module.scss index 59c738b9cc..d359bc38eb 100644 --- a/app/javascript/mastodon/components/badge/styles.module.scss +++ b/app/javascript/mastodon/components/badge/styles.module.scss @@ -7,9 +7,9 @@ gap: 4px; border-radius: 8px; align-items: center; - overflow-wrap: anywhere; > svg { + flex-shrink: 0; width: auto; height: 17px; fill: currentColor; @@ -26,6 +26,12 @@ } } +.content { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .domain { opacity: 0.75; letter-spacing: 0; diff --git a/app/javascript/mastodon/components/poll.tsx b/app/javascript/mastodon/components/poll.tsx index 3ab31f4229..c77ce8bcee 100644 --- a/app/javascript/mastodon/components/poll.tsx +++ b/app/javascript/mastodon/components/poll.tsx @@ -70,7 +70,7 @@ export const Poll: React.FC = ({ pollId, disabled, status }) => { if (expired) { return intl.formatMessage(messages.closed); } - return ; + return ; }, [expired, intl, poll]); const votesCount = useMemo(() => { if (!poll) { diff --git a/app/javascript/mastodon/components/relative_timestamp/index.tsx b/app/javascript/mastodon/components/relative_timestamp/index.tsx index 493e535a71..fa6200b7d1 100644 --- a/app/javascript/mastodon/components/relative_timestamp/index.tsx +++ b/app/javascript/mastodon/components/relative_timestamp/index.tsx @@ -23,16 +23,16 @@ export const RelativeTimestamp: FC<{ timestamp: string; long?: boolean; noTime?: boolean; - noFuture?: boolean; -}> = ({ timestamp, long = false, noTime = false, noFuture = false }) => { + hasFuture?: boolean; +}> = ({ timestamp, long = false, noTime = false, hasFuture = false }) => { const intl = useIntl(); const [now, setNow] = useState(() => Date.now()); const date = useMemo(() => { const date = new Date(timestamp); - return noFuture ? new Date(Math.min(date.getTime(), now)) : date; - }, [noFuture, now, timestamp]); + return !hasFuture ? new Date(Math.min(date.getTime(), now)) : date; + }, [hasFuture, now, timestamp]); const ts = date.getTime(); useEffect(() => { diff --git a/app/javascript/mastodon/components/relative_timestamp/relative_timestamp.stories.tsx b/app/javascript/mastodon/components/relative_timestamp/relative_timestamp.stories.tsx index 978382515d..96965d3f3a 100644 --- a/app/javascript/mastodon/components/relative_timestamp/relative_timestamp.stories.tsx +++ b/app/javascript/mastodon/components/relative_timestamp/relative_timestamp.stories.tsx @@ -11,7 +11,7 @@ const meta = { timestamp: new Date(Date.now() - DAY * 3).toISOString(), long: false, noTime: false, - noFuture: false, + hasFuture: false, }, argTypes: { timestamp: { @@ -44,10 +44,10 @@ export const DateOnly: Story = { }, }; -export const NoFuture: Story = { +export const HasFuture: Story = { args: { timestamp: new Date(Date.now() + DAY * 3).toISOString(), - noFuture: true, + hasFuture: true, }, }; diff --git a/app/javascript/mastodon/components/status/header.tsx b/app/javascript/mastodon/components/status/header.tsx index 9b1445e9c1..65790bb493 100644 --- a/app/javascript/mastodon/components/status/header.tsx +++ b/app/javascript/mastodon/components/status/header.tsx @@ -56,10 +56,7 @@ export const StatusHeader: FC = ({ className='status__relative-time' > - + {editedAt && } diff --git a/app/javascript/mastodon/components/truncated_list/index.tsx b/app/javascript/mastodon/components/truncated_list/index.tsx new file mode 100644 index 0000000000..75e5a4ee25 --- /dev/null +++ b/app/javascript/mastodon/components/truncated_list/index.tsx @@ -0,0 +1,107 @@ +import { useCallback, useState } from 'react'; + +import { Article } from '@/mastodon/components/scrollable_list/components'; +import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react'; +import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react'; + +import { Icon } from '../icon'; +import type { IconProp } from '../icon'; +import { ListItemButton, ListItemWrapper } from '../list_item'; + +export interface TruncatedListItemInfo { + item: TListItem; + index: number; + totalListLength: number; + isLastElement: boolean; +} + +interface ToggleButtonOptions { + title: NonNullable; + subtitle?: React.ReactNode; + icon?: IconProp; +} + +interface TruncatedListProps { + visibleItems: TListItem[]; + truncatedItems: TListItem[]; + renderListItem: ( + itemInfo: TruncatedListItemInfo, + ) => React.ReactElement; + toggleButton: ToggleButtonOptions; +} + +/** + * Truncate the children of an `ItemList` component with this helper + * component. + * It handles rendering the children with correct indexes for accessibility, + * and has a configurable toggle button. + */ +export const TruncatedListItems = ({ + visibleItems, + truncatedItems, + toggleButton, + renderListItem, +}: TruncatedListProps) => { + const [showTruncatedItems, setShowTruncatedItems] = useState(false); + const toggleTruncatedItems = useCallback(() => { + setShowTruncatedItems((prev) => !prev); + }, []); + + const hasHiddenAccounts = truncatedItems.length > 0; + // Add the toggle button's item to the list size when needed + const initialListSize = visibleItems.length + (hasHiddenAccounts ? 1 : 0); + const totalListLength = + initialListSize + (showTruncatedItems ? truncatedItems.length : 0); + + return ( + <> + {visibleItems.map((item, index) => { + return renderListItem({ + item, + index, + totalListLength, + isLastElement: + index === visibleItems.length - 1 && !hasHiddenAccounts, + }); + })} + {hasHiddenAccounts && ( +
+ + ) + } + iconEnd={ + + } + > + + {toggleButton.title} + + +
+ )} + {showTruncatedItems && + truncatedItems.map((item, index) => { + return renderListItem({ + item, + index: initialListSize + index + 1, + totalListLength, + isLastElement: index === truncatedItems.length - 1, + }); + })} + + ); +}; diff --git a/app/javascript/mastodon/features/account_featured/components/empty_message.module.scss b/app/javascript/mastodon/features/account_featured/components/empty_message.module.scss new file mode 100644 index 0000000000..25dcf19433 --- /dev/null +++ b/app/javascript/mastodon/features/account_featured/components/empty_message.module.scss @@ -0,0 +1,8 @@ +[data-color-scheme='dark'] .image { + --color-skin-1: #3a3a50; + --color-skin-2: #67678e; + --color-skin-3: #44445f; + --color-outline: #21212c; + --color-shadow: #181820; + --color-highlight: #b2b1c8; +} diff --git a/app/javascript/mastodon/features/account_featured/components/empty_message.tsx b/app/javascript/mastodon/features/account_featured/components/empty_message.tsx index 2503de2aa0..35b67cd641 100644 --- a/app/javascript/mastodon/features/account_featured/components/empty_message.tsx +++ b/app/javascript/mastodon/features/account_featured/components/empty_message.tsx @@ -5,17 +5,17 @@ import { FormattedMessage } from 'react-intl'; import { useParams } from 'react-router'; import { Link } from 'react-router-dom'; -import ElephantDarkImage from '@/images/elephant_ui_dark.svg?react'; -import ElephantLightImage from '@/images/elephant_ui_light.svg?react'; +import ElephantImage from '@/images/elephant_ui.svg?react'; import { openModal } from '@/mastodon/actions/modal'; import { Button } from '@/mastodon/components/button'; import { EmptyState } from '@/mastodon/components/empty_state'; import { LimitedAccountHint } from '@/mastodon/features/account_timeline/components/limited_account_hint'; import { areCollectionsEnabled } from '@/mastodon/features/collections/utils'; import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; -import { useTheme } from '@/mastodon/hooks/useTheme'; import { useAppDispatch } from '@/mastodon/store'; +import classes from './empty_message.module.scss'; + interface EmptyMessageProps { suspended: boolean; hidden: boolean; @@ -33,9 +33,6 @@ export const EmptyMessage: React.FC = ({ }) => { const { acct } = useParams<{ acct?: string }>(); const me = useCurrentAccountId(); - const theme = useTheme(); - const ElephantImage = - theme === 'dark' ? ElephantDarkImage : ElephantLightImage; const dispatch = useAppDispatch(); @@ -57,7 +54,7 @@ export const EmptyMessage: React.FC = ({ const hasCollections = areCollectionsEnabled(); - const image = ; + const image = ; if (me === accountId) { if (hasCollections) { diff --git a/app/javascript/mastodon/features/account_featured/index.tsx b/app/javascript/mastodon/features/account_featured/index.tsx index 1b83b66dfd..9acb7ff65c 100644 --- a/app/javascript/mastodon/features/account_featured/index.tsx +++ b/app/javascript/mastodon/features/account_featured/index.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -6,10 +6,9 @@ import { useHistory } from 'react-router'; import { List as ImmutableList } from 'immutable'; -import { AccountListItem } from '@/mastodon/components/account_list_item'; -import { useAccount } from '@/mastodon/hooks/useAccount'; import AddIcon from '@/material-icons/400-24px/add.svg?react'; import { fetchEndorsedAccounts } from 'mastodon/actions/accounts'; +import { AccountListItem } from 'mastodon/components/account_list_item'; import { ColumnBackButton } from 'mastodon/components/column_back_button'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { RemoteHint } from 'mastodon/components/remote_hint'; @@ -18,9 +17,12 @@ import { ItemList, Scrollable, } from 'mastodon/components/scrollable_list/components'; +import type { TruncatedListItemInfo } from 'mastodon/components/truncated_list'; +import { TruncatedListItems } from 'mastodon/components/truncated_list'; import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import Column from 'mastodon/features/ui/components/column'; +import { useAccount } from 'mastodon/hooks/useAccount'; import { useAccountId } from 'mastodon/hooks/useAccountId'; import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility'; import { @@ -74,11 +76,32 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ const { collections, status: collectionsLoadStatus } = useAppSelector( (state) => selectAccountCollections(state, accountId ?? null), ); - const listedCollections = collections.filter( - // Hide unlisted and empty collections to avoid confusion - // (Unlisted collections will only be part of the payload - // when viewing your own profile.) - (item) => item.discoverable && !!item.item_count, + + const { listedCollections = [], unlistedCollections = [] } = Object.groupBy( + collections, + (item) => + item.discoverable && !!item.item_count + ? 'listedCollections' + : 'unlistedCollections', + ); + + const renderListItem = useCallback( + ({ + item, + index, + totalListLength, + isLastElement, + }: TruncatedListItemInfo<(typeof listedCollections)[number]>) => ( + + ), + [], ); const hasCollections = @@ -167,16 +190,26 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ {hasCollections ? ( - {listedCollections.map((item, index) => ( - - ))} + + ), + subtitle: ( + + ), + }} + renderListItem={renderListItem} + /> ) : ( = ({ className='status__relative-time' > - + {editedAt && } diff --git a/app/javascript/mastodon/features/collections/detail/accounts_list.tsx b/app/javascript/mastodon/features/collections/detail/accounts_list.tsx index affbd9ad03..1127bdad24 100644 --- a/app/javascript/mastodon/features/collections/detail/accounts_list.tsx +++ b/app/javascript/mastodon/features/collections/detail/accounts_list.tsx @@ -2,13 +2,6 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { - ListItemButton, - ListItemWrapper, -} from '@/mastodon/components/list_item'; -import { createAppSelector, useAppSelector } from '@/mastodon/store'; -import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react'; -import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react'; import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import type { RenderButtonOptions } from 'mastodon/components/account_list_item'; @@ -18,13 +11,15 @@ import { } from 'mastodon/components/account_list_item'; import { Button } from 'mastodon/components/button'; import { Callout } from 'mastodon/components/callout'; -import { Icon } from 'mastodon/components/icon'; import { Article, ItemList, } from 'mastodon/components/scrollable_list/components'; +import type { TruncatedListItemInfo } from 'mastodon/components/truncated_list'; +import { TruncatedListItems } from 'mastodon/components/truncated_list'; import { me } from 'mastodon/initial_state'; import type { Account } from 'mastodon/models/account'; +import { createAppSelector, useAppSelector } from 'mastodon/store'; import { useConfirmRevoke } from './revoke_collection_inclusion_modal'; import classes from './styles.module.scss'; @@ -99,13 +94,9 @@ export const CollectionAccountsList: React.FC<{ const intl = useIntl(); const confirmRevoke = useConfirmRevoke(collection); const listHeadingRef = useRef(null); - const [canShowHiddenAccounts, setCanShowHiddenAccounts] = useState(false); - const toggleHiddenAccounts = useCallback(() => { - setCanShowHiddenAccounts((prev) => !prev); - }, []); const isOwnCollection = collection?.account_id === me; - const { items = [], account_id: collectionOwnerId, id } = collection ?? {}; + const { account_id: collectionOwnerId, id } = collection ?? {}; const relationships = useAppSelector((state) => state.relationships); const collectionAccounts = useAppSelector((state) => @@ -135,9 +126,6 @@ export const CollectionAccountsList: React.FC<{ return { visibleAccounts, hiddenAccounts }; }, [collectionAccounts, relationships]); - const hasHiddenAccounts = hiddenAccounts.length > 0; - const initialListSize = visibleAccounts.length + (hasHiddenAccounts ? 1 : 0); - const renderAccountItemButton = useCallback( ({ relationship, accountId }: RenderButtonOptions) => { // When viewing your own collection, only show the Follow button @@ -165,6 +153,28 @@ export const CollectionAccountsList: React.FC<{ [collectionOwnerId, confirmRevoke], ); + const renderListItem = useCallback( + ({ + item, + index, + totalListLength, + isLastElement, + }: TruncatedListItemInfo) => ( +
+ +
+ ), + [renderAccountItemButton], + ); + return ( <>

- {visibleAccounts.map(({ id }, index) => ( -
- -
- ))} - {hasHiddenAccounts && ( -
- } - iconEnd={ - - } - > - - } - > - - - -
- )} - {canShowHiddenAccounts && - hiddenAccounts.map(({ id }, index) => ( -
- -
- ))} + ), + subtitle: ( + + ), + }} + renderListItem={renderListItem} + /> )} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 76bf0fba87..8b05678fc7 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -409,6 +409,8 @@ "collections.sensitive": "Sensitive", "collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.", "collections.topic_special_chars_hint": "Special characters will be removed when saving", + "collections.unlisted_collections_description": "These don’t appear on your profile to others. Anyone with the link can discover them.", + "collections.unlisted_collections_with_count": "Unlisted collections ({count})", "collections.view_collection": "View collection", "collections.view_other_collections_by_user": "View other collections by this user", "collections.visibility_public": "Public", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index e172591ba6..2349bc74de 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -10657,14 +10657,7 @@ noscript { } &__source { - overflow: hidden; - white-space: nowrap; cursor: help; - - > span { - overflow: hidden; - text-overflow: ellipsis; - } } } diff --git a/app/models/email_subscription.rb b/app/models/email_subscription.rb index fc3a0f125d..b108ef19c5 100644 --- a/app/models/email_subscription.rb +++ b/app/models/email_subscription.rb @@ -19,7 +19,7 @@ class EmailSubscription < ApplicationRecord normalizes :email, with: ->(str) { str.squish.downcase } - validates :email, presence: true, email_address: true, uniqueness: { scope: :account_id } + validates :email, presence: true, email_address: true, length: { maximum: 320 }, uniqueness: { scope: :account_id } validates :email, email_mx: true, if: -> { email_changed? && !Rails.env.local? } scope :confirmed, -> { where.not(confirmed_at: nil) } diff --git a/app/models/user.rb b/app/models/user.rb index 7b53c0e4f8..461e7089fc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -92,7 +92,7 @@ class User < ApplicationRecord accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? && !Setting.require_invite_text } validates :invite_request, presence: true, on: :create, if: :invite_text_required? - validates :email, presence: true, email_address: true + validates :email, presence: true, email_address: true, length: { maximum: 320 } validates :email, email_mx: { attempt_ip: :sign_up_ip }, if: :validate_email_dns? validates_with UserEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? } diff --git a/app/validators/email_address_validator.rb b/app/validators/email_address_validator.rb index ed0bb11652..7cc303a636 100644 --- a/app/validators/email_address_validator.rb +++ b/app/validators/email_address_validator.rb @@ -11,8 +11,14 @@ class EmailAddressValidator < ActiveModel::EachValidator value = value.strip address = Mail::Address.new(value) - record.errors.add(attribute, :invalid) if address.address != value + record.errors.add(attribute, :invalid) if address.address != value || contains_disallowed_characters?(value) rescue Mail::Field::FieldError record.errors.add(attribute, :invalid) end + + private + + def contains_disallowed_characters?(value) + value.include?('%') || value.include?(',') || value.include?('"') + end end diff --git a/docker-compose.yml b/docker-compose.yml index 72b087e34a..e1ea73cda2 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: ghcr.io/glitch-soc/mastodon:v4.5.8 + image: ghcr.io/glitch-soc/mastodon:v4.5.9 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: ghcr.io/glitch-soc/mastodon-streaming:v4.5.8 + image: ghcr.io/glitch-soc/mastodon-streaming:v4.5.9 restart: always env_file: .env.production command: node ./streaming/index.js @@ -102,7 +102,7 @@ services: sidekiq: # 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/glitch-soc/mastodon:v4.5.8 + image: ghcr.io/glitch-soc/mastodon:v4.5.9 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/docs/hero-nodes.gif b/docs/hero-nodes.gif new file mode 100644 index 0000000000..5692e2ef7c Binary files /dev/null and b/docs/hero-nodes.gif differ diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 2e82d2844b..2a61199c05 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -17,7 +17,7 @@ module Mastodon end def default_prerelease - 'alpha.6' + 'alpha.7' end def prerelease