diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 21c7ec3db..000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,36 +0,0 @@ -version: 2.1 - -jobs: - deploy: - docker: - - image: cimg/node:24.12.0 - steps: - - checkout - - run: - name: Install pl-api deps - working_directory: packages/pl-api - command: pnpm install - - run: - name: Build pl-api - working_directory: packages/pl-api - command: pnpm run build - - run: - name: Install Nicolium deps - working_directory: packages/nicolium - command: pnpm install - - run: - name: Build Nicolium - working_directory: packages/nicolium - command: pnpm run build - - run: - name: Install Vercel CLI - command: sudo npm install -g vercel - - run: - working_directory: packages/nicolium - name: Deploy to Vercel - command: vercel deploy --token $VERCEL_TOKEN --prod=false --confirm --name nicolium - -workflows: - deploy_on_pr: - jobs: - - deploy diff --git a/.github/workflows/nicolium.yaml b/.github/workflows/nicolium.yaml index ba1f3d7f4..5a1727f40 100644 --- a/.github/workflows/nicolium.yaml +++ b/.github/workflows/nicolium.yaml @@ -35,7 +35,7 @@ jobs: - name: Install deps working-directory: . - run: pnpm install + run: pnpm install --ignore-scripts - name: Build pl-api env: diff --git a/.github/workflows/pl-api.yaml b/.github/workflows/pl-api.yaml index 8bf9663c0..6434b7b5f 100644 --- a/.github/workflows/pl-api.yaml +++ b/.github/workflows/pl-api.yaml @@ -34,7 +34,7 @@ jobs: - name: Install deps working-directory: . - run: pnpm install + run: pnpm install --ignore-scripts - name: Lint working-directory: ./packages/pl-api diff --git a/.github/workflows/pl-hooks.yaml b/.github/workflows/pl-hooks.yaml index 6e4565ee1..b1b474139 100644 --- a/.github/workflows/pl-hooks.yaml +++ b/.github/workflows/pl-hooks.yaml @@ -34,7 +34,7 @@ jobs: - name: Install deps working-directory: . - run: pnpm install + run: pnpm install --ignore-scripts - name: Build pl-api env: diff --git a/.npmrc b/.npmrc index f775ec646..a362aec5f 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ @transfem-org:registry=https://activitypub.software/api/v4/packages/npm/ +ignore-scripts=true diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 33be54684..31aa3bd64 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,9 +1,7 @@ { "recommendations": [ "dbaeumer.vscode-eslint", - "bradlc.vscode-tailwindcss", "stylelint.vscode-stylelint", - "wix.vscode-import-cost", - "bradlc.vscode-tailwindcss" + "wix.vscode-import-cost" ] } diff --git a/docs/installing/mitra.md b/docs/installing/mitra.md index 9f3d8615c..765afb68d 100644 --- a/docs/installing/mitra.md +++ b/docs/installing/mitra.md @@ -8,6 +8,8 @@ order: 31 Installing Nicolium as a frontend for Mitra is no different from installing the default Mitra Web frontend. Just extract the Nicolium files into the directory specified in `config.yaml` under `web_client_dir`, by default `/usr/share/mitra/www`. +> **Note:** This assumes you want to use the stable release version of Nicolium. If you want to use the development version (which is more cutting-edge but can break sometimes), replace `release` with `develop` in the URLs and commands below. + ```bash curl -O https://web.nicolium.app/release.zip unzip release.zip -d /usr/share/mitra/www diff --git a/docs/installing/pleroma-akkoma.md b/docs/installing/pleroma-akkoma.md index b5c5d154b..09360f1b5 100644 --- a/docs/installing/pleroma-akkoma.md +++ b/docs/installing/pleroma-akkoma.md @@ -10,6 +10,8 @@ order: 32 The most straightforward way to install Nicolium as a frontend for Pleroma or Akkoma is to simply download it and place its files in the `/instance/static` directory of your Pleroma/Akkoma installation (usually `/opt/pleroma/instance/static` or `/opt/akkoma/instance/static`, accordingly). +> **Note:** This assumes you want to use the stable release version of Nicolium. If you want to use the development version (which is more cutting-edge but can break sometimes), replace `release` with `develop` in the URLs and commands below. + ```bash curl -O https://web.nicolium.app/release.zip unzip release.zip -d /opt/pleroma/instance/static/ diff --git a/docs/installing/standalone.md b/docs/installing/standalone.md index 52b881ef9..1f3e2d513 100644 --- a/docs/installing/standalone.md +++ b/docs/installing/standalone.md @@ -19,4 +19,4 @@ nicolium.example.com { } ``` -This assumes you're serving Nicolium under the nicolium.example.com domain and the Nicolium files are located in `/var/www/nicolium`. You can download Nicolium from `https://web.nicolium.app/release.zip` or [build it from source](../building/nicolium.md). \ No newline at end of file +This assumes you're serving Nicolium under the nicolium.example.com domain and the Nicolium files are located in `/var/www/nicolium`. You can download Nicolium from `https://web.nicolium.app/release.zip` (`https://web.nicolium.app/develop.zip` for the development version) or [build it from source](../building/nicolium.md). \ No newline at end of file diff --git a/packages/nicolium/CHANGELOG.md b/packages/nicolium/CHANGELOG.md index 648499f85..0d4a52c10 100644 --- a/packages/nicolium/CHANGELOG.md +++ b/packages/nicolium/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## v0.1.2 + +### Changed + +- Collapsed posts can be expanded without switching to the thread view. +- Posts can be previewed on touchscreen devices by clicking on the 'Replying to' text on a reply. +- Continued work on migrating styles from TailwindCSS. +- Some dropdown menus now don't get opened as a modal on mobile. +- Updated feature detection for Hollo. + +### Fixed + +- Finished the content-type bugfixes. +- Polling is now disabled when the instance doesn't support `since_id` parameter. +- It's now possible to edit a multilingual post. +- Unread notification count on Hollo is now handled correctly. + ## v0.1.1 ### Added diff --git a/packages/nicolium/index.html b/packages/nicolium/index.html index 04fd3aa79..c31064981 100644 --- a/packages/nicolium/index.html +++ b/packages/nicolium/index.html @@ -30,7 +30,7 @@ To use Nicolium, please enable JavaScript.
What is Nicolium?What’s Nicolium? diff --git a/packages/nicolium/src/assets/images/avatar-missing.png b/packages/nicolium/src/assets/images/avatar-missing.png index 6b49628bb..341aba65b 100644 Binary files a/packages/nicolium/src/assets/images/avatar-missing.png and b/packages/nicolium/src/assets/images/avatar-missing.png differ diff --git a/packages/nicolium/src/components/accounts/account-hover-card.tsx b/packages/nicolium/src/components/accounts/account-hover-card.tsx index e63049958..06ee0b131 100644 --- a/packages/nicolium/src/components/accounts/account-hover-card.tsx +++ b/packages/nicolium/src/components/accounts/account-hover-card.tsx @@ -112,6 +112,13 @@ const AccountHoverCard: React.FC = ({ visible = true }) => { }; }, []); + useEffect(() => { + if (account && !ref?.current) { + showAccountHoverCard.cancel(); + closeAccountHoverCard(true); + } + }, [!!account]); + const { x, y, strategy, refs, context, placement } = useFloating({ open: !!account, elements: { diff --git a/packages/nicolium/src/components/accounts/account.tsx b/packages/nicolium/src/components/accounts/account.tsx index 0a7cd3cd7..b4fff747d 100644 --- a/packages/nicolium/src/components/accounts/account.tsx +++ b/packages/nicolium/src/components/accounts/account.tsx @@ -95,15 +95,6 @@ const InstanceFavicon: React.FC = ({ account, disabled }) => { ); }; -interface IProfilePopper { - condition: boolean; - wrapper: (children: React.ReactNode) => React.ReactNode; - children: React.ReactNode; -} - -const ProfilePopper: React.FC = ({ condition, wrapper, children }) => - condition ? wrapper(children) : children; - interface IAccount { account: AccountSchema; action?: React.ReactElement; @@ -118,7 +109,6 @@ interface IAccount { onActionClick?: (account: AccountSchema) => void; showAccountHoverCard?: boolean; timestamp?: string; - timestampUrl?: string; futureTimestamp?: boolean; withAccountNote?: boolean; withAvatar?: boolean; @@ -148,7 +138,6 @@ const Account = ({ onActionClick, showAccountHoverCard = true, timestamp, - timestampUrl, futureTimestamp = false, withAccountNote = false, withAvatar = true, @@ -350,6 +339,159 @@ const Account = ({ ); + const containerClassName = clsx( + 'flex max-w-full items-center gap-3', + withAccountNote || note ? 'items-start' : 'items-center', + ); + + const body = ( + <> + {withAvatar && + (disableUserProvidedMedia ? ( + + ) : ( + + + {emoji && ( + + )} + + ))} + +
+ +
+ + + + + {account.verified && } + + {account.bot && ( + } + /> + )} +
+
+ +
+ {' '} +
+ + @{username} + + + {withLocked && !timestamp && account.locked && ( + <> + + {account.favicon && !disableUserProvidedMedia && } + + )} + + {account.favicon && !disableUserProvidedMedia && ( + + )} + + {timestamp ? ( + <> + + + + + ) : null} + + {approvalStatus && ['pending', 'rejected'].includes(approvalStatus) && ( + <> + + + + {approvalStatus === 'pending' ? ( + + ) : ( + + )} + + + )} + + {actionType === 'blocking' && blockExpiresAt ? ( + <> + + + + + + + ) : null} + + {actionType === 'muting' && muteExpiresAt ? ( + <> + + + + + + + ) : null} + + {items} +
+ {note ? ( + + {note} + + ) : ( + withAccountNote && ( + + + + ) + )} +
+
+ + ); + return (
-
- {withAvatar && - (disableUserProvidedMedia ? ( - - ) : ( - ( - - {children} - - )} - > - - - - - {emoji && ( - - )} - - - ))} - -
- ( - - {children} - - )} - > - -
- - - - - {account.verified && } - - {account.bot && ( - } - /> - )} -
-
-
- -
- {' '} -
- - @{username} - - - {withLocked && !timestamp && account.locked && ( - <> - - {account.favicon && !disableUserProvidedMedia && ( - - )} - - )} - - {account.favicon && !disableUserProvidedMedia && ( - - )} - - {timestamp ? ( - <> - - - {timestampUrl ? ( - event.stopPropagation()} - > - - - ) : ( - - )} - - ) : null} - - {approvalStatus && ['pending', 'rejected'].includes(approvalStatus) && ( - <> - - - - {approvalStatus === 'pending' ? ( - - ) : ( - - )} - - - )} - - {actionType === 'blocking' && blockExpiresAt ? ( - <> - - - - - - - ) : null} - - {actionType === 'muting' && muteExpiresAt ? ( - <> - - - - - - - ) : null} - - {items} -
- {note ? ( - - {note} - - ) : ( - withAccountNote && ( - - - - ) - )} -
-
-
+ {showAccountHoverCard ? ( + + {body} + + ) : ( +
{body}
+ )}
{renderAction()}
diff --git a/packages/nicolium/src/components/accounts/hover-account-wrapper.tsx b/packages/nicolium/src/components/accounts/hover-account-wrapper.tsx index bca222bd3..20ef39bc6 100644 --- a/packages/nicolium/src/components/accounts/hover-account-wrapper.tsx +++ b/packages/nicolium/src/components/accounts/hover-account-wrapper.tsx @@ -7,7 +7,7 @@ import { isMobile } from '@/utils/is-mobile'; const showAccountHoverCard = debounce((openAccountHoverCard, ref, accountId) => { openAccountHoverCard(ref, accountId); -}, 600); +}, 300); interface IHoverAccountWrapper { accountId?: string; diff --git a/packages/nicolium/src/components/dropdown-menu/dropdown-menu-item.tsx b/packages/nicolium/src/components/dropdown-menu/dropdown-menu-item.tsx index 434c584db..4d179ec54 100644 --- a/packages/nicolium/src/components/dropdown-menu/dropdown-menu-item.tsx +++ b/packages/nicolium/src/components/dropdown-menu/dropdown-menu-item.tsx @@ -140,30 +140,18 @@ const DropdownMenuItem = ({ index, item, onClick, autoFocus, onSetTab }: IDropdo onKeyDown={handleItemKeyDown} target={typeof item.target === 'string' ? item.target : '_blank'} title={item.text} - className={clsx( - 'mx-2 my-1 flex cursor-pointer items-center rounded-md px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300 rtl:flex-row-reverse', - { - 'text-danger-600 dark:text-danger-400': item.destructive, - 'cursor-not-allowed opacity-50': item.disabled, - 'hover:bg-gray-100 hover:text-gray-800 focus:bg-gray-100 focus:text-gray-800 focus:outline-none black:hover:bg-gray-900 black:focus:bg-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-200 dark:focus:bg-gray-800 dark:focus:text-gray-200': - !item.disabled, - }, - )} + aria-disabled={item.disabled} + className={clsx('⁂-dropdown-menu__item', { + '⁂-dropdown-menu__item--destructive': item.destructive, + })} > - {item.icon && } + {item.icon && } -
+
{item.meta ? ( <> -
{item.text}
-
{item.meta}
+
{item.text}
+
{item.meta}
) : ( item.text @@ -171,13 +159,13 @@ const DropdownMenuItem = ({ index, item, onClick, autoFocus, onSetTab }: IDropdo
{item.count ? ( - + ) : null} {(item.type === 'toggle' || item.type === 'radio') && ( -
+
+ )} diff --git a/packages/nicolium/src/components/dropdown-menu/dropdown-menu.tsx b/packages/nicolium/src/components/dropdown-menu/dropdown-menu.tsx index 7e38f901d..e4c52be0d 100644 --- a/packages/nicolium/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/nicolium/src/components/dropdown-menu/dropdown-menu.tsx @@ -50,6 +50,8 @@ interface IDropdownMenu { title?: string; width?: React.CSSProperties['width']; className?: string; + /** Forces the dropdown to be displayed as a dropdown menu, not in a modal. */ + forceDropdown?: boolean; } const listenerOptions = supportsPassiveEvents ? { passive: true } : false; @@ -135,7 +137,7 @@ const DropdownMenuContent: React.FC = ({ const handleDocumentClick = useMemo( () => (event: Event) => { - if (ref.current && !ref.current.contains(event.target as Node)) { + if (ref.current && !ref.current.contains(event.target as Node) && event.type !== 'touchend') { handleClose(); event.stopPropagation(); } @@ -239,6 +241,7 @@ const DropdownMenu: React.FC = (props) => { title = 'Menu', width, className, + forceDropdown, } = props; const { openDropdownMenu, closeDropdownMenu } = useUiStoreActions(); @@ -288,7 +291,7 @@ const DropdownMenu: React.FC = (props) => { }; const handleOpen = () => { - if (userTouching.matches) { + if (userTouching.matches && !forceDropdown) { const handleClose = () => { closeModal('DROPDOWN_MENU'); }; diff --git a/packages/nicolium/src/components/navigation/dropdown-navigation.tsx b/packages/nicolium/src/components/navigation/dropdown-navigation.tsx index d97547758..d2c39fec6 100644 --- a/packages/nicolium/src/components/navigation/dropdown-navigation.tsx +++ b/packages/nicolium/src/components/navigation/dropdown-navigation.tsx @@ -54,6 +54,7 @@ import { useInstance } from '@/stores/instance'; import { useSettings } from '@/stores/settings'; import { useIsSidebarOpen, useUiStoreActions } from '@/stores/ui'; import sourceCode from '@/utils/code'; +import { useIsStandalone } from '@/utils/state'; import type { Account as AccountEntity } from 'pl-api'; @@ -184,6 +185,7 @@ const DropdownNavigation: React.FC = React.memo((): React.JSX.Element | null => const touchStart = useRef(0); const touchEnd = useRef(null); const { isOpen } = useRegistrationStatus(); + const standalone = useIsStandalone(); const instance = useInstance(); const timelineAccess = instance.configuration.timelines_access; @@ -556,51 +558,55 @@ const DropdownNavigation: React.FC = React.memo((): React.JSX.Element | null =>
) : (
- {features.publicTimeline && timelineAccess.live_feeds.local === 'public' && ( - <> - - ) : ( - - ) - } - onClick={closeSidebar} - /> - - {features.bubbleTimeline && timelineAccess.live_feeds.bubble === 'public' && ( + {!standalone && + features.publicTimeline && + timelineAccess.live_feeds.local === 'public' && ( + <> } + to='/timeline/local' + icon={iconPlanet} + text={ + features.federating ? ( + + ) : ( + + ) + } onClick={closeSidebar} /> - )} - {features.federating && timelineAccess.live_feeds.remote === 'public' && ( - } - onClick={closeSidebar} - /> - )} + {features.bubbleTimeline && timelineAccess.live_feeds.bubble === 'public' && ( + } + onClick={closeSidebar} + /> + )} - {features.wrenchedTimeline && timelineAccess.live_feeds.wrenched === 'public' && ( - } - onClick={closeSidebar} - /> - )} + {features.federating && timelineAccess.live_feeds.remote === 'public' && ( + } + onClick={closeSidebar} + /> + )} - - - )} + {features.wrenchedTimeline && timelineAccess.live_feeds.wrenched === 'public' && ( + + } + onClick={closeSidebar} + /> + )} + + + + )} { openStatusHoverCard(ref, statusId); @@ -41,7 +41,15 @@ const HoverStatusWrapper: React.FC = ({ }, 200); }; - const handleClick = () => { + const handleClick: React.MouseEventHandler = (event) => { + if (userTouching.matches) { + event.preventDefault(); + event.stopPropagation(); + + openStatusHoverCard(ref as React.RefObject, statusId); + return; + } + showStatusHoverCard.cancel(); closeStatusHoverCard(true); }; diff --git a/packages/nicolium/src/components/statuses/status-action-bar.tsx b/packages/nicolium/src/components/statuses/status-action-bar.tsx index fe7f04e44..89e8f9f72 100644 --- a/packages/nicolium/src/components/statuses/status-action-bar.tsx +++ b/packages/nicolium/src/components/statuses/status-action-bar.tsx @@ -597,7 +597,12 @@ const ReblogButton: React.FC = ({ ]; return ( - + {reblogButton} ); @@ -828,7 +833,7 @@ const MenuButton: React.FC = ({ const client = useClient(); const { fetchTranslation, hideTranslation } = useStatusMetaActions(); - const { targetLanguage, expanded } = useStatusMeta(status.id); + const { targetLanguage, spoilerExpanded } = useStatusMeta(status.id); const { openModal } = useModalsActions(); const { data: group } = useGroupQuery(status.group_id || undefined, true); const { mutate: blockGroupMember } = useBlockGroupUserMutation( @@ -1109,7 +1114,7 @@ const MenuButton: React.FC = ({ }); } - if (status.spoiler_text.length === 0 || (expanded ?? false)) { + if (status.spoiler_text.length === 0 || (spoilerExpanded ?? false)) { menu.push({ text: intl.formatMessage(messages.copyStatus), action: handleCopyStatus, @@ -1432,7 +1437,7 @@ const MenuButton: React.FC = ({ status.pinned, status.reblogged, status.account?.relationship, - expanded, + spoilerExpanded, ]); return useMemo( diff --git a/packages/nicolium/src/components/statuses/status-content.tsx b/packages/nicolium/src/components/statuses/status-content.tsx index 6fdca21e8..1158eefca 100644 --- a/packages/nicolium/src/components/statuses/status-content.tsx +++ b/packages/nicolium/src/components/statuses/status-content.tsx @@ -51,6 +51,30 @@ const ReadMoreButton: React.FC = ({ onClick, preview }) => (
); +interface IExpandButton { + onClick: React.MouseEventHandler; + expanded?: boolean; +} + +const ExpandButton: React.FC = ({ onClick, expanded }) => ( + <> +
+ {!expanded &&
} +
+ + +); + interface IStatusContent { status: NormalizedStatus; onClick?: () => void; @@ -62,6 +86,7 @@ interface IStatusContent { withMedia?: boolean; compose?: boolean; isEvent?: boolean; + expandable?: boolean; } /** Renders the text content of a status */ @@ -77,6 +102,7 @@ const StatusContent: React.FC = React.memo( withMedia, compose = false, isEvent = false, + expandable = false, }) => { const { urlPrivacy, displaySpoilers, renderMfm, displayMentionAvatars } = useSettings(); const { greentext } = useFrontendConfig(); @@ -89,7 +115,8 @@ const StatusContent: React.FC = React.memo( const contentNode = useRef(null); const spoilerNode = useRef(null); - const { collapseStatuses, expandStatuses } = useStatusMetaActions(); + const { collapseStatuses, expandStatuses, collapseStatusSpoilers, expandStatusSpoilers } = + useStatusMetaActions(); const statusMeta = useStatusMeta(status.id); const { data: translation } = useStatusTranslation(status.id, statusMeta.targetLanguage); const { data: localTranslation } = useLocalStatusTranslation( @@ -97,8 +124,9 @@ const StatusContent: React.FC = React.memo( statusMeta.localTargetLanguage, ); - const withSpoiler = status.spoiler_text?.length > 0; - const expanded = !withSpoiler || (statusMeta.expanded ?? false); + const withSpoiler = status.spoiler_text.length > 0; + const { expanded } = statusMeta; + const spoilerExpanded = !withSpoiler || (statusMeta.spoilerExpanded ?? false); const maybeSetCollapsed = (): void => { if (!contentNode.current) return; @@ -121,20 +149,30 @@ const StatusContent: React.FC = React.memo( } }; - const toggleExpanded: React.MouseEventHandler = (e) => { + const toggleSpoilerExpanded: React.MouseEventHandler = (e) => { e.preventDefault(); e.stopPropagation(); + if (spoilerExpanded) { + collapseStatusSpoilers([status.id]); + setCollapsed(null); + } else expandStatusSpoilers([status.id]); + }; + + const toggleExpanded: React.MouseEventHandler = (e) => { + e.preventDefault(); + e.stopPropagation(); if (expanded) { collapseStatuses([status.id]); - setCollapsed(null); - } else expandStatuses([status.id]); + } else { + expandStatuses([status.id]); + } }; useLayoutEffect(() => { maybeSetCollapsed(); maybeSetOnlyEmoji(); - }, [expanded]); + }, [spoilerExpanded]); const content = useMemo( (): string => @@ -194,23 +232,23 @@ const StatusContent: React.FC = React.memo( const className = useMemo( () => clsx('⁂-status-content', { - 'overflow-hidden': collapsed, - 'max-h-[200px]': collapsed && !isQuote && !preview, - 'max-h-[120px]': collapsed && isQuote, - 'max-h-[80px]': collapsed && preview, - 'max-h-[282px]': collapsable && collapsed === null && !isQuote && !preview, - 'max-h-[202px]': collapsable && collapsed === null && isQuote, - 'max-h-[82px]': collapsed === null && preview, + 'overflow-hidden': collapsed && !expanded, + 'max-h-[200px]': collapsed && !isQuote && !preview && !expanded, + 'max-h-[120px]': collapsed && isQuote && !expanded, + 'max-h-[80px]': collapsed && preview && !expanded, + 'max-h-[282px]': collapsable && collapsed === null && !isQuote && !preview && !expanded, + 'max-h-[202px]': collapsable && collapsed === null && isQuote && !expanded, + 'max-h-[82px]': collapsed === null && preview && !expanded, 'big-emoji leading-normal': onlyEmoji, - '⁂-status-content--expanded': !collapsable, + '⁂-status-content--spoiler-expanded': !collapsable, '⁂-status-content--quote': isQuote, '⁂-status-content--preview': preview, '⁂-status-content--poll': !!status.poll_id, }), - [collapsed, onlyEmoji], + [collapsed, onlyEmoji, spoilerExpanded, expanded], ); - const expandable = !displaySpoilers && !isEvent; + const hasSpoiler = !displaySpoilers && !isEvent; const output = []; @@ -218,20 +256,21 @@ const StatusContent: React.FC = React.memo( output.push(

- {expandable && ( -