Merge branch 'develop' of https://codeberg.org/mkljczk/pl-fe into develop

This commit is contained in:
2026-03-31 15:19:35 +00:00
17 changed files with 87 additions and 38 deletions

View File

@@ -1,5 +1,23 @@
# Changelog
## v0.1.1
### Added
- Missing ARIA attributes and other minor accessibility improvements.
### Changed
- Continued work on migrating styles from TailwindCSS.
- Some files were moved to different locations.
- Added a warning related to CORS configuration when signing in to an external instance.
### Fixed
- Removed `console.log` statement accidentally left in the release code.
- Requests without body do not get `Content-Type: application/json` appended by default, which fixes a bug with GoToSocial.
- Last status in a thread view, if expanded, no longer gets bottom padding.
## v0.1.0
> This list includes changes made since the project forked from Soapbox in April 2024. It does not cover every UI change, consistency improvement, optimization, accessibility improvement, or backend compatibility update — maintaining such a list manually would be impractical.

View File

@@ -10,12 +10,11 @@ import FaviconService from '@/utils/favicon-service';
FaviconService.initFaviconService();
interface IHelmet {
interface IHeadTitle {
title?: string;
children?: React.ReactNode;
}
const Helmet: React.FC<IHelmet> = ({ title, children }) => {
const HeadTitle: React.FC<IHeadTitle> = ({ title }) => {
const instance = useInstance();
const { unreadChatsCount } = useStatContext();
const { data: awaitingApprovalCount = 0 } = usePendingUsersCount();
@@ -45,12 +44,7 @@ const Helmet: React.FC<IHelmet> = ({ title, children }) => {
? addCounter(`${title} | ${instance.title}`)
: addCounter(instance.title);
return (
<>
<title>{formattedTitle}</title>
{children}
</>
);
return <title>{formattedTitle}</title>;
};
export { Helmet as default };
export { HeadTitle as default };

View File

@@ -613,6 +613,7 @@ const Audio: React.FC<IAudio> = (props) => {
href={src}
download
target='_blank'
rel='noopener noreferrer'
>
<Icon src={iconDownloadSimple} />
</a>

View File

@@ -621,6 +621,7 @@ const Video: React.FC<IVideo> = ({
href={src}
download
target='_blank'
rel='noopener noreferrer'
>
<Icon src={iconDownloadSimple} />
</a>

View File

@@ -3,7 +3,7 @@ import clsx from 'clsx';
import throttle from 'lodash/throttle';
import React, { useCallback, useEffect, useState } from 'react';
import Helmet from '@/components/helmet';
import HeadTitle from '@/components/helmet';
import { useFrontendConfig } from '@/hooks/use-frontend-config';
import { Card, CardBody, CardHeader, CardTitle, type CardSizes } from './card';
@@ -125,14 +125,13 @@ const Column: React.FC<IColumn> = (props): React.JSX.Element => {
variant={transparent ? undefined : 'rounded'}
className={clsx('⁂-column', className)}
>
<Helmet title={label}>
{frontendConfig.appleAppId && (
<meta
name='apple-itunes-app'
content={`app-id=${frontendConfig.appleAppId}, app-argument=${location.href}`}
/>
)}
</Helmet>
<HeadTitle title={label} />
{frontendConfig.appleAppId && (
<meta
name='apple-itunes-app'
content={`app-id=${frontendConfig.appleAppId}, app-argument=${location.href}`}
/>
)}
{withHeader && (
<ColumnHeader

View File

@@ -33,7 +33,7 @@ const IconButton = React.forwardRef(
className={clsx('⁂-icon-button', `⁂-icon-button--${theme}`, className)}
{...filteredProps}
data-testid={filteredProps['data-testid'] ?? 'icon-button'}
{...(props.href ? { target: '_blank' } : {})}
{...(props.href ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
<SvgIcon src={src} className={iconClassName} aria-hidden />

View File

@@ -243,7 +243,7 @@ const Thread = ({
const renderChildren = (list: Array<string>) =>
list.map((id) => {
if (id === status.id)
if (id === status.id) {
return (
<div className={clsx({ 'pb-4': hasDescendants })} key={status.id}>
{deleted ? (
@@ -289,6 +289,7 @@ const Thread = ({
)}
</div>
);
}
if (id.endsWith('-tombstone')) {
return renderTombstone(id);
@@ -328,7 +329,7 @@ const Thread = ({
[status.id],
);
const hasDescendants = thread.length > statusIndex;
const hasDescendants = thread.length > statusIndex + 1;
type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void };

View File

@@ -8,10 +8,11 @@ import { useLocale, useLocaleDirection } from '@/hooks/use-locale';
import { useTheme } from '@/hooks/use-theme';
import { useThemeCss } from '@/hooks/use-theme-css';
import { startSentry } from '@/sentry';
import { useInstanceStore } from '@/stores/instance';
import { useHasModals } from '@/stores/modals';
import { useSettings } from '@/stores/settings';
const Helmet = React.lazy(() => import('@/components/helmet'));
const HeadTitle = React.lazy(() => import('@/components/helmet'));
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
@@ -30,6 +31,7 @@ const NicoliumHead = () => {
const theme = useTheme();
const [wcoVisible, setWcoVisible] = React.useState(false);
const [wcoRight, setWcoRight] = React.useState(false);
const instanceFetched = useInstanceStore((state) => state.fetched);
const withModals = useHasModals();
@@ -96,7 +98,7 @@ const NicoliumHead = () => {
return (
<>
<Helmet />
{instanceFetched && <HeadTitle />}
<meta name='theme-color' content={color} />
<InlineStyle>{`:root { ${themeCss} }`}</InlineStyle>
{['dark', 'black'].includes(theme) && (

View File

@@ -28,14 +28,20 @@ const NicoliumLoad: React.FC<INicoliumLoad> = ({ children }) => {
/** Whether to display a loading indicator. */
const showLoading = [me === null, me && !account, !isLoaded, localeLoading].some(Boolean);
console.log('[NicoliumLoad]', { me, account: !!account, isLoaded, localeLoading, showLoading });
// Load the user's locale
useEffect(() => {
console.log('[NicoliumLoad] Loading locale:', locale);
MESSAGES[locale]()
.then((messages) => {
console.log('[NicoliumLoad] Locale loaded');
setMessages(messages);
setLocaleLoading(false);
})
.catch(() => {});
.catch((e) => {
console.error('[NicoliumLoad] Locale failed:', e);
});
}, [locale]);
// Load initial data from the API
@@ -43,19 +49,21 @@ const NicoliumLoad: React.FC<INicoliumLoad> = ({ children }) => {
/** Load initial data from the backend */
const loadInitial = async () => {
checkIfStandalone();
// Await for authenticated fetch
console.log('[NicoliumLoad] fetchMe...');
await fetchMe();
// Await for feature detection
console.log('[NicoliumLoad] fetchInstance...');
await fetchInstance();
// Await for configuration
console.log('[NicoliumLoad] loadFrontendConfig...');
await loadFrontendConfig();
console.log('[NicoliumLoad] All loaded');
};
loadInitial()
.then(() => {
setIsLoaded(true);
})
.catch(() => {
.catch((e) => {
console.error('[NicoliumLoad] loadInitial failed:', e);
setIsLoaded(true);
});
}, []);

View File

@@ -1417,7 +1417,8 @@
"login.otp_log_in.fail": "Invalid code, please try again.",
"login.reset_password_hint": "Trouble logging in?",
"login.sign_in": "Sign in",
"login_external.errors.instance_fail": "The instance returned an error.",
"login_external.errors.cors_fail": "Connection failed, likely due to CORS configuration. Is the instance configured to allow logins from other origins?",
"login_external.errors.instance_fail": "The instance returned an error. Is the URL correct?",
"login_external.errors.network_fail": "Connection failed. Is a browser extension blocking it?",
"login_form.divider": "or",
"login_form.external": "Sign in from remote instance",

View File

@@ -13,7 +13,12 @@ const messages = defineMessages({
instancePlaceholder: { id: 'login.fields.instance_placeholder', defaultMessage: 'example.com' },
instanceFailed: {
id: 'login_external.errors.instance_fail',
defaultMessage: 'The instance returned an error.',
defaultMessage: 'The instance returned an error. Is the URL correct?',
},
corsFailed: {
id: 'login_external.errors.cors_fail',
defaultMessage:
'Connection failed, likely due to CORS configuration. Is the instance configured to allow logins from other origins?',
},
networkFailed: {
id: 'login_external.errors.network_fail',
@@ -47,8 +52,10 @@ const ExternalLoginForm: React.FC = () => {
console.error(error);
const status = error.response?.status;
if (status) {
if (status || !error.message) {
toast.error(intl.formatMessage(messages.instanceFailed));
} else if (error.message === 'NetworkError when attempting to fetch resource.') {
toast.error(intl.formatMessage(messages.corsFailed));
} else if (!status && ['Network request failed', 'Timeout'].includes(error.message)) {
toast.error(intl.formatMessage(messages.networkFailed));
}

View File

@@ -60,7 +60,7 @@ const useTimeline = (
const poll = async () => {
const sinceId =
useTimelinesStore.getState().timelines[timelineId]?.queuedEntries[0]?.id ??
useTimelinesStore.getState().timelines[timelineId]?.newestStatusId ??
newestStatusId.current;
if (!sinceId) return;

View File

@@ -46,6 +46,7 @@ interface TimelineData {
isError: boolean;
hasNextPage: boolean;
oldestStatusId?: string;
newestStatusId?: string;
}
interface State {
@@ -202,6 +203,9 @@ const useTimelinesStore = create<State>()(
}
timeline.isPending = false;
timeline.isFetching = false;
if ((initialFetch || restoring) && statuses.length > 0) {
timeline.newestStatusId = statuses[0].id;
}
if (typeof hasMore === 'boolean') {
timeline.hasNextPage = hasMore;
const oldestStatus = statuses.at(-1);
@@ -220,9 +224,12 @@ const useTimelinesStore = create<State>()(
)
return;
if (!timeline.newestStatusId || timeline.newestStatusId.localeCompare(status.id) < 0) {
timeline.newestStatusId = status.id;
}
timeline.queuedEntries.unshift(status);
timeline.queuedCount += 1;
timeline.queuedAccountIds.unshift(status.account.id);
timeline.queuedAccountIds.unshift((status.reblog || status).account.id);
});
},
deleteStatus: (statusId) => {
@@ -269,6 +276,7 @@ const useTimelinesStore = create<State>()(
const processedEntries = processPage(timeline.queuedEntries);
timeline.newestStatusId = timeline.queuedEntries.toSorted().at(-1)!.id;
timeline.entries.unshift(...processedEntries);
timeline.queuedEntries = [];
timeline.queuedCount = 0;

View File

@@ -13,6 +13,10 @@ const compareId = (id1: string, id2: string) => {
}
if (id1.length === id2.length) {
return id1 > id2 ? 1 : -1;
} else if (id1.includes('/') && id2.includes('/')) {
// Hollo notification IDs consist of a date, notification type and a UUID.
// If both IDs start with a date, we can compare just the date part.
return id1.split('/')[0] > id2.split('/')[0] ? 1 : -1;
} else {
return id1.length > id2.length ? 1 : -1;
}

View File

@@ -906,6 +906,7 @@ const getFeatures = (instance: Instance) => {
v.software === AKKOMA,
v.software === FIREFISH,
v.software === GOTOSOCIAL,
v.software === HOLLO,
v.software === MASTODON,
v.software === MITRA,
v.software === PLEROMA,
@@ -1390,6 +1391,7 @@ const getFeatures = (instance: Instance) => {
polls: any([
v.software === FIREFISH,
v.software === GOTOSOCIAL,
v.software === HOLLO,
v.software === ICESHRIMP,
v.software === ICESHRIMP_NET,
v.software === MASTODON,
@@ -1407,6 +1409,7 @@ const getFeatures = (instance: Instance) => {
postLanguages: any([
v.software === AKKOMA,
v.software === GOTOSOCIAL,
v.software === HOLLO,
v.software === MASTODON,
v.software === MITRA && gte(v.version, '3.23.0'),
v.software === PLEROMA && gte(v.version, '2.9.0'),
@@ -1473,6 +1476,7 @@ const getFeatures = (instance: Instance) => {
v.software === FIREFISH,
v.software === FRIENDICA,
v.software === GOTOSOCIAL,
v.software === HOLLO,
v.software === ICESHRIMP,
v.software === ICESHRIMP_NET,
v.software === MASTODON,

View File

@@ -99,7 +99,7 @@ function request<T = any>(
params,
onUploadProgress,
signal,
contentType = 'application/json',
contentType,
formData,
idempotencyKey,
}: RequestBody = {},
@@ -113,7 +113,8 @@ function request<T = any>(
else if (this.accessToken) headers.set('Authorization', `Bearer ${this.accessToken}`);
else if (this.customAuthorizationToken)
headers.set('Authorization', this.customAuthorizationToken);
if (!formData) headers.set('Content-Type', contentType);
if ((!formData && body) || contentType)
headers.set('Content-Type', contentType || 'application/json');
if (idempotencyKey) headers.set('Idempotency-Key', idempotencyKey);
body = body && formData ? serialize(body, { indices: true }) : JSON.stringify(body);

View File

@@ -1,7 +1,7 @@
/** Default header filenames from various backends */
const DEFAULT_HEADERS: Array<string | RegExp> = [
'/assets/default_header.webp', // GoToSocial
'/headers/original/missing.png', // Mastodon
'/headers/original/missing.png', // Hollo, Mastodon
'/api/v1/accounts/identicon', // Mitra
/\/static\/img\/missing\.[a-z0-9]+\.png$/, // NeoDB
'/storage/headers/missing.png', // Pixelfed
@@ -19,7 +19,7 @@ const isDefaultHeader = (url: string = '') =>
/** Default avatar filenames from various backends */
const DEFAULT_AVATARS: Array<string | RegExp> = [
/\/assets\/default_avatars\/GoToSocial_icon[1-6]\.webp$/, // GoToSocial
'/avatars/original/missing.png', // Mastodon
'/avatars/original/missing.png', // Hollo, Mastodon
'/api/v1/accounts/identicon', // Mitra
'/s/img/avatar.svg', // NeoDB
'/avatars/default.jpg', // Pixelfed