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

This commit is contained in:
2026-01-17 18:51:17 +00:00
16 changed files with 154 additions and 70 deletions

View File

@ -14,10 +14,9 @@ import Text from 'pl-fe/components/ui/text';
import VerificationBadge from 'pl-fe/components/verification-badge';
import Emojify from 'pl-fe/features/emoji/emojify';
import ActionButton from 'pl-fe/features/ui/components/action-button';
import { useAcct } from 'pl-fe/hooks/use-acct';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useSettings } from 'pl-fe/stores/settings';
import { getAcct } from 'pl-fe/utils/accounts';
import { displayFqn } from 'pl-fe/utils/state';
import Badge from './badge';
import { ParsedContent } from './parsed-content';
@ -148,7 +147,7 @@ const Account = ({
const [style, setStyle] = useState<React.CSSProperties>({});
const me = useAppSelector((state) => state.me);
const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null);
const username = useAcct(account);
const { disableUserProvidedMedia } = useSettings();
const handleAction = () => {

View File

@ -37,7 +37,7 @@ const SiteError: ErrorRouteComponent = ({ error, info }) => {
const sentryEnabled = Boolean(sentryDsn);
const isProduction = NODE_ENV === 'production';
const errorText = String(error) + info?.componentStack;
const errorText = String(error) + (info?.componentStack || '');
const clearCookies: React.MouseEventHandler = (e) => {
localStorage.clear();

View File

@ -279,10 +279,6 @@ const Header: React.FC<IHeader> = ({ account }) => {
unblockDomain(domain);
};
const onProfileExternal = (url: string) => {
window.open(url, '_blank');
};
const onAddToList = () => {
openModal('LIST_ADDER', {
accountId: account.id,
@ -380,7 +376,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
menu.push({
text: intl.formatMessage(messages.profileExternal, { domain }),
action: () => onProfileExternal(account.url),
href: account.url,
icon: require('@phosphor-icons/core/regular/arrow-square-out.svg'),
});
}

View File

@ -191,10 +191,6 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
dispatch(initReport(ReportableEntities.STATUS, account, { status }));
};
const handleModerateStatus = () => {
window.open(`/pleroma/admin/#/statuses/${status.id}/`, '_blank');
};
const handleToggleStatusSensitivity = () => {
dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive));
};
@ -349,7 +345,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
if (isAdmin && features.pleromaAdminStatuses) {
menu.push({
text: intl.formatMessage(messages.adminStatus),
action: handleModerateStatus,
href: `/pleroma/admin/#/statuses/${status.id}/`,
icon: require('@phosphor-icons/core/regular/pencil-simple.svg'),
});
}

View File

@ -67,7 +67,6 @@ const ModalRoot: React.FC = () => {
const index = modals.length - 1;
const onClickClose = (type?: ModalType, all?: boolean) => {
console.log('Closing modal:', type, all);
switch (type) {
case 'COMPOSE':
dispatch(cancelReplyCompose());

View File

@ -12,8 +12,8 @@ import Icon from 'pl-fe/components/ui/icon';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import Emojify from 'pl-fe/features/emoji/emojify';
import { useAcct } from 'pl-fe/hooks/use-acct';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { usePlFeConfig } from 'pl-fe/hooks/use-pl-fe-config';
import { accountScrobbleQueryOptions } from 'pl-fe/queries/accounts/account-scrobble';
import { capitalize } from 'pl-fe/utils/strings';
@ -40,7 +40,7 @@ interface IProfileInfoPanel {
/** User profile metadata, such as location, birthday, etc. */
const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) => {
const intl = useIntl();
const { displayFqn } = usePlFeConfig();
const acct = useAcct(account);
const me = useAppSelector(state => state.me);
const ownAccount = account?.id === me;
@ -152,7 +152,7 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
<HStack alignItems='center' space={0.5}>
<Text size='sm' theme='muted' direction='ltr' truncate>
@{displayFqn ? account.fqn : account.acct}
@{acct}
</Text>
{account.locked && (

View File

@ -11,11 +11,9 @@ import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import VerificationBadge from 'pl-fe/components/verification-badge';
import Emojify from 'pl-fe/features/emoji/emojify';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useAcct } from 'pl-fe/hooks/use-acct';
import { useSettings } from 'pl-fe/stores/settings';
import { getAcct } from 'pl-fe/utils/accounts';
import { shortNumberFormat } from 'pl-fe/utils/numbers';
import { displayFqn } from 'pl-fe/utils/state';
const messages = defineMessages({
account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
@ -32,7 +30,7 @@ const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain })
const intl = useIntl();
const { demetricator, disableUserProvidedMedia } = useSettings();
const { account } = useAccount(accountId);
const fqn = useAppSelector((state) => displayFqn(state));
const displayedAcct = useAcct(account);
if (!account) return null;
const acct = !account.acct.includes('@') && domain ? `${account.acct}@${domain}` : account.acct;
@ -96,7 +94,7 @@ const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain })
<HStack alignItems='center' space={1}>
<Text size='sm' theme='muted' direction='ltr' truncate>
@{getAcct(account, fqn)}
@{displayedAcct}
</Text>
{account.locked && (

View File

@ -0,0 +1,28 @@
import { useMemo } from 'react';
import { displayFqn } from 'pl-fe/utils/state';
import { useAppSelector } from './use-app-selector';
import { useInstance } from './use-instance';
import { useOwnAccount } from './use-own-account';
import type { Account } from 'pl-api';
const useAcct = (account?: Pick<Account, 'fqn' | 'acct' | 'local' | 'url'>): string | undefined => {
const fqn = useAppSelector((state) => displayFqn(state));
const instance = useInstance();
const localUrl = useOwnAccount().account?.url;
return useMemo(() => {
if (!account) return;
if (!fqn) return account.acct;
const localHost = localUrl ? new URL(localUrl).host : null;
const otherHost = new URL(account.url).host;
if (account.local === false || (localHost && localHost !== otherHost)) return account.fqn;
return `${account.acct}@${instance.domain}`;
}, [account?.acct, fqn, instance.domain, localUrl]);
};
export {
useAcct,
};

View File

@ -19,10 +19,9 @@ import {
PinnedAccountsPanel,
AccountNotePanel,
} from 'pl-fe/features/ui/util/async-components';
import { useAcct } from 'pl-fe/hooks/use-acct';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useFeatures } from 'pl-fe/hooks/use-features';
import { usePlFeConfig } from 'pl-fe/hooks/use-pl-fe-config';
import { getAcct } from 'pl-fe/utils/accounts';
/** Layout to display a user's profile. */
const ProfileLayout: React.FC = () => {
@ -33,7 +32,7 @@ const ProfileLayout: React.FC = () => {
const me = useAppSelector(state => state.me);
const features = useFeatures();
const { displayFqn } = usePlFeConfig();
const acct = useAcct(account);
if (isUnauthorized) {
localStorage.setItem('plfe:redirect_uri', location.href);
@ -101,7 +100,7 @@ const ProfileLayout: React.FC = () => {
</Helmet>
)}
<Layout.Main>
<Column size='lg' label={account ? `@${getAcct(account, displayFqn)}` : ''} withHeader={false}>
<Column size='lg' label={account ? `@${acct}` : ''} withHeader={false}>
<div className='space-y-4'>
<Header key={`profile-header-${account?.id}`} account={account} />
<ProfileInfoPanel username={username} account={account} />

View File

@ -1,8 +1,9 @@
import { animated, useSpring } from '@react-spring/web';
import { Link } from '@tanstack/react-router';
import { useDrag } from '@use-gesture/react';
import clsx from 'clsx';
import React, { type RefCallback, useCallback, useEffect, useState } from 'react';
import React, { type RefCallback, useCallback, useEffect, useMemo, useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import ReactSwipeableViews from 'react-swipeable-views';
import { fetchStatusWithContext } from 'pl-fe/actions/statuses';
import ExtendedVideoPlayer from 'pl-fe/components/extended-video-player';
@ -25,6 +26,8 @@ import { makeGetStatus } from 'pl-fe/selectors';
import type { MediaAttachment } from 'pl-api';
import type { BaseModalProps } from 'pl-fe/features/ui/components/modal-root';
const MIN_SWIPE_DISTANCE = 400;
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
expand: { id: 'lightbox.expand', defaultMessage: 'Expand' },
@ -36,19 +39,6 @@ const messages = defineMessages({
download: { id: 'video.download', defaultMessage: 'Download file' },
});
// you can't use 100vh, because the viewport height is taller
// than the visible part of the document in some mobile
// browsers when its address bar is visible.
// https://developers.google.com/web/updates/2016/12/url-bar-resizing
const swipeableViewsStyle: React.CSSProperties = {
width: '100%',
height: '100%',
};
const containerStyle: React.CSSProperties = {
alignItems: 'center', // center vertically
};
interface MediaModalProps {
media?: Array<MediaAttachment>;
statusId?: string;
@ -77,6 +67,32 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
const [navigationHidden, setNavigationHidden] = useState(false);
const [isFullScreen, setIsFullScreen] = useState(!status);
const [wrapperStyles, api] = useSpring(() => ({
x: `-${index * 100}%`,
}));
const handleChangeIndex = useCallback(
(newIndex: number, animate = false) => {
if (newIndex < 0) {
newIndex = media.length + newIndex;
} else if (newIndex >= media.length) {
newIndex = newIndex % media.length;
}
setIndex(newIndex);
setZoomedIn(false);
if (animate) {
void api.start({ x: `calc(-${newIndex * 100}% + 0px)` });
}
},
[api, media.length],
);
const handlePrevClick = useCallback(() => {
handleChangeIndex(index - 1, true);
}, [handleChangeIndex, index]);
const handleNextClick = useCallback(() => {
handleChangeIndex(index + 1, true);
}, [handleChangeIndex, index]);
const [viewportDimensions, setViewportDimensions] = useState<{
width: number;
height: number;
@ -93,10 +109,6 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
const hasMultipleImages = media.length > 1;
const handleSwipe = (index: number) => setIndex(index % media.length);
const handleNextClick = () => setIndex((index + 1) % media.length);
const handlePrevClick = () => setIndex((media.length + index - 1) % media.length);
const navigationHiddenClassName = navigationHidden ? 'pointer-events-none opacity-0' : '';
const handleKeyDown = (e: KeyboardEvent) => {
@ -114,6 +126,30 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
}
};
const bind = useDrag(
({ active, movement: [mx], direction: [xDir], cancel }) => {
// Disable swipe when zoomed in.
if (zoomedIn) {
return;
}
// If dragging and swipe distance is enough, change the index.
if (
active &&
Math.abs(mx) > Math.min(window.innerWidth / 4, MIN_SWIPE_DISTANCE)
) {
handleChangeIndex(index - xDir);
cancel();
}
// Set the x position via calc to ensure proper centering regardless of screen size.
const x = active ? mx : 0;
void api.start({
x: `calc(-${index * 100}% + ${x}px)`,
});
},
{ pointer: { capture: false } },
);
const handleDownload = () => {
const mediaItem = hasMultipleImages ? media[index as number] : media[0];
window.open(mediaItem?.url);
@ -133,7 +169,7 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
setZoomedIn((prev) => !prev);
}, []);
const content = media.map((attachment, i) => {
const content = useMemo(() => media.map((attachment, i) => {
let width: number | undefined, height: number | undefined;
if (attachment.type === 'image' || attachment.type === 'gifv' || attachment.type === 'video') {
width = (attachment.meta?.original?.width);
@ -209,7 +245,7 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
}
return null;
});
}), [media.length, index, zoomedIn, handleZoomClick]);
// Load data.
useEffect(() => {
@ -313,6 +349,7 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
{/* Height based on height of top/bottom bars */}
<div
{...bind()}
className='relative h-[calc(100vh-120px)] w-full grow'
>
{hasMultipleImages && (
@ -328,14 +365,14 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
</div>
)}
<ReactSwipeableViews
style={swipeableViewsStyle}
containerStyle={containerStyle}
onChangeIndex={handleSwipe}
index={index}
<animated.div
style={wrapperStyles}
className='media-modal__closer'
role='presentation'
onClick={() => onClose()}
>
{content}
</ReactSwipeableViews>
</animated.div>
{hasMultipleImages && (
<div className={clsx('absolute inset-y-0 right-5 z-10 flex items-center transition-opacity', navigationHiddenClassName)}>

View File

@ -1,4 +1,6 @@
.media-modal {
touch-action: pan-y;
.audio-player.detailed,
.extended-video-player {
@apply flex items-center justify-center;
@ -15,5 +17,23 @@
@apply max-w-full max-h-[80%];
}
}
&__closer {
display: flex;
position: absolute;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
bottom: 0;
> div {
flex-shrink: 0;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
}
}

View File

@ -306,7 +306,8 @@ body {
z-index: 1;
&__content {
@apply mx-auto w-full max-w-3xl grow black:pt-0 sm:px-6 sm:pt-4 md:gap-4 md:px-8;
// FIXME: min-h-[100dvh] is a workaround for some Ladybird bug
@apply mx-auto w-full max-w-3xl grow black:pt-0 sm:px-6 sm:pt-4 md:gap-4 md:px-8 min-h-[100dvh];
&--full-width {
@apply flex md:max-w-full;

View File

@ -22,12 +22,7 @@ const getBaseURL = (account: Pick<Account, 'url'>): string => {
}
};
const getAcct = (account: Pick<Account, 'fqn' | 'acct'>, displayFqn: boolean): string => (
displayFqn === true ? account.fqn : account.acct
);
export {
getDomain,
getBaseURL,
getAcct,
};