diff --git a/packages/nicolium/CHANGELOG.md b/packages/nicolium/CHANGELOG.md index 19699d1e8..648499f85 100644 --- a/packages/nicolium/CHANGELOG.md +++ b/packages/nicolium/CHANGELOG.md @@ -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. diff --git a/packages/nicolium/src/components/helmet.tsx b/packages/nicolium/src/components/helmet.tsx index f9dac5bd0..9bcd6f166 100644 --- a/packages/nicolium/src/components/helmet.tsx +++ b/packages/nicolium/src/components/helmet.tsx @@ -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 = ({ title, children }) => { +const HeadTitle: React.FC = ({ title }) => { const instance = useInstance(); const { unreadChatsCount } = useStatContext(); const { data: awaitingApprovalCount = 0 } = usePendingUsersCount(); @@ -45,12 +44,7 @@ const Helmet: React.FC = ({ title, children }) => { ? addCounter(`${title} | ${instance.title}`) : addCounter(instance.title); - return ( - <> - {formattedTitle} - {children} - - ); + return {formattedTitle}; }; -export { Helmet as default }; +export { HeadTitle as default }; diff --git a/packages/nicolium/src/components/media/audio.tsx b/packages/nicolium/src/components/media/audio.tsx index 15705828b..87a69933a 100644 --- a/packages/nicolium/src/components/media/audio.tsx +++ b/packages/nicolium/src/components/media/audio.tsx @@ -613,6 +613,7 @@ const Audio: React.FC = (props) => { href={src} download target='_blank' + rel='noopener noreferrer' > diff --git a/packages/nicolium/src/components/media/video.tsx b/packages/nicolium/src/components/media/video.tsx index e8c82527b..94961b0fa 100644 --- a/packages/nicolium/src/components/media/video.tsx +++ b/packages/nicolium/src/components/media/video.tsx @@ -621,6 +621,7 @@ const Video: React.FC = ({ href={src} download target='_blank' + rel='noopener noreferrer' > diff --git a/packages/nicolium/src/components/ui/column.tsx b/packages/nicolium/src/components/ui/column.tsx index 60cf9bdf2..273d61397 100644 --- a/packages/nicolium/src/components/ui/column.tsx +++ b/packages/nicolium/src/components/ui/column.tsx @@ -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 = (props): React.JSX.Element => { variant={transparent ? undefined : 'rounded'} className={clsx('⁂-column', className)} > - - {frontendConfig.appleAppId && ( - - )} - + + {frontendConfig.appleAppId && ( + + )} {withHeader && ( diff --git a/packages/nicolium/src/features/status/components/thread.tsx b/packages/nicolium/src/features/status/components/thread.tsx index 0c223e91d..366ffb0b7 100644 --- a/packages/nicolium/src/features/status/components/thread.tsx +++ b/packages/nicolium/src/features/status/components/thread.tsx @@ -243,7 +243,7 @@ const Thread = ({ const renderChildren = (list: Array) => list.map((id) => { - if (id === status.id) + if (id === status.id) { return (
{deleted ? ( @@ -289,6 +289,7 @@ const Thread = ({ )}
); + } 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 }; diff --git a/packages/nicolium/src/init/nicolium-head.tsx b/packages/nicolium/src/init/nicolium-head.tsx index a434ad424..4f34da019 100644 --- a/packages/nicolium/src/init/nicolium-head.tsx +++ b/packages/nicolium/src/init/nicolium-head.tsx @@ -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 ( <> - + {instanceFetched && } {`:root { ${themeCss} }`} {['dark', 'black'].includes(theme) && ( diff --git a/packages/nicolium/src/init/nicolium-load.tsx b/packages/nicolium/src/init/nicolium-load.tsx index b32e613eb..a67e57a15 100644 --- a/packages/nicolium/src/init/nicolium-load.tsx +++ b/packages/nicolium/src/init/nicolium-load.tsx @@ -28,14 +28,20 @@ const NicoliumLoad: React.FC = ({ 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 = ({ 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); }); }, []); diff --git a/packages/nicolium/src/locales/en.json b/packages/nicolium/src/locales/en.json index 673cec352..f59164666 100644 --- a/packages/nicolium/src/locales/en.json +++ b/packages/nicolium/src/locales/en.json @@ -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", diff --git a/packages/nicolium/src/pages/auth/components/external-login-form.tsx b/packages/nicolium/src/pages/auth/components/external-login-form.tsx index a8402458e..3e93c2eb6 100644 --- a/packages/nicolium/src/pages/auth/components/external-login-form.tsx +++ b/packages/nicolium/src/pages/auth/components/external-login-form.tsx @@ -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)); } diff --git a/packages/nicolium/src/queries/timelines/use-timeline.ts b/packages/nicolium/src/queries/timelines/use-timeline.ts index a9d6b5587..5ee5aada7 100644 --- a/packages/nicolium/src/queries/timelines/use-timeline.ts +++ b/packages/nicolium/src/queries/timelines/use-timeline.ts @@ -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; diff --git a/packages/nicolium/src/stores/timelines.ts b/packages/nicolium/src/stores/timelines.ts index e9a67f27f..28c42e3e0 100644 --- a/packages/nicolium/src/stores/timelines.ts +++ b/packages/nicolium/src/stores/timelines.ts @@ -46,6 +46,7 @@ interface TimelineData { isError: boolean; hasNextPage: boolean; oldestStatusId?: string; + newestStatusId?: string; } interface State { @@ -202,6 +203,9 @@ const useTimelinesStore = create()( } 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()( ) 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()( const processedEntries = processPage(timeline.queuedEntries); + timeline.newestStatusId = timeline.queuedEntries.toSorted().at(-1)!.id; timeline.entries.unshift(...processedEntries); timeline.queuedEntries = []; timeline.queuedCount = 0; diff --git a/packages/nicolium/src/utils/comparators.ts b/packages/nicolium/src/utils/comparators.ts index aaa43e21d..b0b4f5fc5 100644 --- a/packages/nicolium/src/utils/comparators.ts +++ b/packages/nicolium/src/utils/comparators.ts @@ -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; } diff --git a/packages/pl-api/lib/features.ts b/packages/pl-api/lib/features.ts index a9fd76be7..15de99ba4 100644 --- a/packages/pl-api/lib/features.ts +++ b/packages/pl-api/lib/features.ts @@ -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, diff --git a/packages/pl-api/lib/request.ts b/packages/pl-api/lib/request.ts index 962a7b188..97b1281a3 100644 --- a/packages/pl-api/lib/request.ts +++ b/packages/pl-api/lib/request.ts @@ -99,7 +99,7 @@ function request( params, onUploadProgress, signal, - contentType = 'application/json', + contentType, formData, idempotencyKey, }: RequestBody = {}, @@ -113,7 +113,8 @@ function request( 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); diff --git a/packages/pl-api/lib/utils/accounts.ts b/packages/pl-api/lib/utils/accounts.ts index 83cd29322..b5e13cb19 100644 --- a/packages/pl-api/lib/utils/accounts.ts +++ b/packages/pl-api/lib/utils/accounts.ts @@ -1,7 +1,7 @@ /** Default header filenames from various backends */ const DEFAULT_HEADERS: Array = [ '/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 = [ /\/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