From f7d2b5c37ff566bd26b92a9c5446110f5c7d2e73 Mon Sep 17 00:00:00 2001 From: Alexander Harding <2166114+aeharding@users.noreply.github.com> Date: Sat, 2 Dec 2023 00:25:57 -0600 Subject: [PATCH 1/8] Refactor IonToggle and cleanup misc runtime warnings (#999) --- src/features/post/new/PostEditorRoot.tsx | 6 +++--- src/features/settings/appearance/CompactSettings.tsx | 5 +++-- src/features/settings/appearance/General.tsx | 5 +++-- src/features/settings/appearance/TextSize.tsx | 10 ++++++---- .../settings/appearance/themes/dark/PureBlack.tsx | 7 ++++--- .../settings/appearance/themes/system/DarkMode.tsx | 7 ++++--- .../settings/biometric/settings/BiometricEnabled.tsx | 9 ++++----- src/features/settings/blocks/FilterNsfw.tsx | 5 +++-- .../settings/general/comments/CollapsedByDefault.tsx | 7 ++++--- .../settings/general/comments/HighlightNewAccount.tsx | 7 ++++--- .../settings/general/comments/ShowCommentImages.tsx | 7 ++++--- .../settings/general/comments/ShowJumpButton.tsx | 7 ++++--- .../settings/general/comments/TouchFriendlyLinks.tsx | 7 ++++--- .../settings/general/hiding/DisableMarkingRead.tsx | 7 ++++--- .../settings/general/hiding/MarkReadOnScroll.tsx | 7 ++++--- .../settings/general/hiding/ShowHideReadButton.tsx | 7 ++++--- .../settings/general/hiding/autoHide/AutoHideRead.tsx | 7 ++++--- .../general/hiding/autoHide/DisableInCommunities.tsx | 7 ++++--- src/features/settings/general/other/Haptics.tsx | 7 ++++--- .../settings/general/other/NoSubscribedInFeed.tsx | 7 ++++--- .../settings/general/posts/InfiniteScrolling.tsx | 7 ++++--- src/features/settings/general/posts/UpvoteOnSave.tsx | 7 ++++--- src/features/settings/gestures/SwipeSettings.tsx | 10 ++++++---- src/features/settings/shared/SettingSelector.tsx | 4 +++- 24 files changed, 95 insertions(+), 71 deletions(-) diff --git a/src/features/post/new/PostEditorRoot.tsx b/src/features/post/new/PostEditorRoot.tsx index cd7bfc8aca..56470ec8d4 100644 --- a/src/features/post/new/PostEditorRoot.tsx +++ b/src/features/post/new/PostEditorRoot.tsx @@ -464,12 +464,12 @@ export default function PostEditorRoot({ )} {showNsfwToggle && ( - NSFW{" "} setNsfw(e.detail.checked)} - /> + > + NSFW + )} - Show Voting Buttons dispatch(setShowVotingButtons(e.detail.checked ? true : false)) } - /> + > + Show Voting Buttons + > diff --git a/src/features/settings/appearance/General.tsx b/src/features/settings/appearance/General.tsx index 08e7fbc919..e27ddb215a 100644 --- a/src/features/settings/appearance/General.tsx +++ b/src/features/settings/appearance/General.tsx @@ -19,7 +19,6 @@ export default function GeneralAppearance() { - Show user instance + > + Show user instance + > diff --git a/src/features/settings/appearance/TextSize.tsx b/src/features/settings/appearance/TextSize.tsx index 418b15da7c..c6abc5ff15 100644 --- a/src/features/settings/appearance/TextSize.tsx +++ b/src/features/settings/appearance/TextSize.tsx @@ -55,13 +55,14 @@ export default function TextSize() { - Use System Text Size dispatch(setUseSystemFontSize(e.detail.checked)) } - /> + > + Use System Text Size + {fontSizeMultiplier >= 1.4 && ( - Larger Text Mode MAX_REGULAR_FONT_ADJUSTMENT} onIonChange={() => @@ -94,7 +94,9 @@ export default function TextSize() { ), ) } - /> + > + Larger Text Mode + )} diff --git a/src/features/settings/appearance/themes/dark/PureBlack.tsx b/src/features/settings/appearance/themes/dark/PureBlack.tsx index 191377d982..45e07a38fa 100644 --- a/src/features/settings/appearance/themes/dark/PureBlack.tsx +++ b/src/features/settings/appearance/themes/dark/PureBlack.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../../store"; import { setPureBlack } from "../../../settingsSlice"; @@ -11,11 +11,12 @@ export default function PureBlack() { return ( - Pure Black Dark Mode dispatch(setPureBlack(e.detail.checked))} - /> + > + Pure Black Dark Mode + ); } diff --git a/src/features/settings/appearance/themes/system/DarkMode.tsx b/src/features/settings/appearance/themes/system/DarkMode.tsx index af3e136d58..2d780c1b77 100644 --- a/src/features/settings/appearance/themes/system/DarkMode.tsx +++ b/src/features/settings/appearance/themes/system/DarkMode.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../../store"; import { setUseSystemDarkMode } from "../../../settingsSlice"; @@ -11,11 +11,12 @@ export default function DarkMode() { return ( - Use System Light/Dark Mode dispatch(setUseSystemDarkMode(e.detail.checked))} - /> + > + Use System Light/Dark Mode + ); } diff --git a/src/features/settings/biometric/settings/BiometricEnabled.tsx b/src/features/settings/biometric/settings/BiometricEnabled.tsx index 38a4d2495f..2881ba2c18 100644 --- a/src/features/settings/biometric/settings/BiometricEnabled.tsx +++ b/src/features/settings/biometric/settings/BiometricEnabled.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../user/Profile"; import BiometricTitle from "../BiometricTitle"; import { useAppDispatch, useAppSelector } from "../../../../store"; @@ -12,13 +12,12 @@ export default function BiometricEnabled() { return ( - - Lock with - dispatch(setBiometricsEnabled(!biometricsEnabled))} - /> + > + Lock with + ); } diff --git a/src/features/settings/blocks/FilterNsfw.tsx b/src/features/settings/blocks/FilterNsfw.tsx index 5fa927f661..2678daca95 100644 --- a/src/features/settings/blocks/FilterNsfw.tsx +++ b/src/features/settings/blocks/FilterNsfw.tsx @@ -17,7 +17,6 @@ export default function FilterNsfw() { - Hide all NSFW { @@ -28,7 +27,9 @@ export default function FilterNsfw() { setLoading(false); } }} - /> + > + Hide all NSFW + diff --git a/src/features/settings/general/comments/CollapsedByDefault.tsx b/src/features/settings/general/comments/CollapsedByDefault.tsx index db0cfa922e..f42b8c24d7 100644 --- a/src/features/settings/general/comments/CollapsedByDefault.tsx +++ b/src/features/settings/general/comments/CollapsedByDefault.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { @@ -15,7 +15,6 @@ export default function CollapsedByDefault() { return ( - Collapse Comment Threads @@ -27,7 +26,9 @@ export default function CollapsedByDefault() { ), ) } - /> + > + Collapse Comment Threads + ); } diff --git a/src/features/settings/general/comments/HighlightNewAccount.tsx b/src/features/settings/general/comments/HighlightNewAccount.tsx index 97186677ce..c0b59cf9d5 100644 --- a/src/features/settings/general/comments/HighlightNewAccount.tsx +++ b/src/features/settings/general/comments/HighlightNewAccount.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { setHighlightNewAccount } from "../../settingsSlice"; @@ -12,11 +12,12 @@ export default function HighlightNewAccount() { return ( - New Account Highlightenator dispatch(setHighlightNewAccount(e.detail.checked))} - /> + > + New Account Highlightenator + ); } diff --git a/src/features/settings/general/comments/ShowCommentImages.tsx b/src/features/settings/general/comments/ShowCommentImages.tsx index 8d88284702..37397f3e82 100644 --- a/src/features/settings/general/comments/ShowCommentImages.tsx +++ b/src/features/settings/general/comments/ShowCommentImages.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { setShowCommentImages } from "../../settingsSlice"; @@ -12,11 +12,12 @@ export default function ShowCommentImages() { return ( - Show Comment Images dispatch(setShowCommentImages(e.detail.checked))} - /> + > + Show Comment Images + ); } diff --git a/src/features/settings/general/comments/ShowJumpButton.tsx b/src/features/settings/general/comments/ShowJumpButton.tsx index 7cb8980200..e98b1a2bce 100644 --- a/src/features/settings/general/comments/ShowJumpButton.tsx +++ b/src/features/settings/general/comments/ShowJumpButton.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { setShowJumpButton } from "../../settingsSlice"; @@ -12,11 +12,12 @@ export default function ShowJumpButton() { return ( - Show Jump Button dispatch(setShowJumpButton(e.detail.checked))} - /> + > + Show Jump Button + ); } diff --git a/src/features/settings/general/comments/TouchFriendlyLinks.tsx b/src/features/settings/general/comments/TouchFriendlyLinks.tsx index c0d667531a..69e54a4c63 100644 --- a/src/features/settings/general/comments/TouchFriendlyLinks.tsx +++ b/src/features/settings/general/comments/TouchFriendlyLinks.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { setTouchFriendlyLinks } from "../../settingsSlice"; @@ -12,11 +12,12 @@ export default function TouchFriendlyLinks() { return ( - Touch Friendly Links dispatch(setTouchFriendlyLinks(e.detail.checked))} - /> + > + Touch Friendly Links + ); } diff --git a/src/features/settings/general/hiding/DisableMarkingRead.tsx b/src/features/settings/general/hiding/DisableMarkingRead.tsx index 3d7332138b..07f5ef7ebc 100644 --- a/src/features/settings/general/hiding/DisableMarkingRead.tsx +++ b/src/features/settings/general/hiding/DisableMarkingRead.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { setDisableMarkingPostsRead } from "../../settingsSlice"; @@ -11,13 +11,14 @@ export default function DisableMarkingRead() { return ( - Disable Marking Posts Read dispatch(setDisableMarkingPostsRead(e.detail.checked)) } - /> + > + Disable Marking Posts Read + ); } diff --git a/src/features/settings/general/hiding/MarkReadOnScroll.tsx b/src/features/settings/general/hiding/MarkReadOnScroll.tsx index d67038e1af..6f8e7e11d3 100644 --- a/src/features/settings/general/hiding/MarkReadOnScroll.tsx +++ b/src/features/settings/general/hiding/MarkReadOnScroll.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { setMarkPostsReadOnScroll } from "../../settingsSlice"; @@ -11,13 +11,14 @@ export default function MarkReadOnScroll() { return ( - Mark Read on Scroll dispatch(setMarkPostsReadOnScroll(e.detail.checked)) } - /> + > + Mark Read on Scroll + ); } diff --git a/src/features/settings/general/hiding/ShowHideReadButton.tsx b/src/features/settings/general/hiding/ShowHideReadButton.tsx index 1e0be35d26..71d3acdccb 100644 --- a/src/features/settings/general/hiding/ShowHideReadButton.tsx +++ b/src/features/settings/general/hiding/ShowHideReadButton.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { setShowHideReadButton } from "../../settingsSlice"; @@ -11,11 +11,12 @@ export default function ShowHideReadButton() { return ( - Show Hide Read Button dispatch(setShowHideReadButton(e.detail.checked))} - /> + > + Show Hide Read Button + ); } diff --git a/src/features/settings/general/hiding/autoHide/AutoHideRead.tsx b/src/features/settings/general/hiding/autoHide/AutoHideRead.tsx index 52a248b32e..ff8e1c236f 100644 --- a/src/features/settings/general/hiding/autoHide/AutoHideRead.tsx +++ b/src/features/settings/general/hiding/autoHide/AutoHideRead.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../../store"; import { setAutoHideRead } from "../../../settingsSlice"; @@ -11,11 +11,12 @@ export default function AutoHideRead() { return ( - Auto Hide Read Posts dispatch(setAutoHideRead(e.detail.checked))} - /> + > + Auto Hide Read Posts + ); } diff --git a/src/features/settings/general/hiding/autoHide/DisableInCommunities.tsx b/src/features/settings/general/hiding/autoHide/DisableInCommunities.tsx index 749daa6683..6e74fd0c55 100644 --- a/src/features/settings/general/hiding/autoHide/DisableInCommunities.tsx +++ b/src/features/settings/general/hiding/autoHide/DisableInCommunities.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../../store"; import { setDisableAutoHideInCommunities } from "../../../settingsSlice"; @@ -11,13 +11,14 @@ export default function DisableInCommunities() { return ( - Disable in Communities dispatch(setDisableAutoHideInCommunities(e.detail.checked)) } - /> + > + Disable in Communities + ); } diff --git a/src/features/settings/general/other/Haptics.tsx b/src/features/settings/general/other/Haptics.tsx index f6a0b70e3a..6550ec765b 100644 --- a/src/features/settings/general/other/Haptics.tsx +++ b/src/features/settings/general/other/Haptics.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { setEnableHapticFeedback } from "../../settingsSlice"; @@ -15,11 +15,12 @@ export default function Haptics() { return ( - Haptic Feedback dispatch(setEnableHapticFeedback(e.detail.checked))} - /> + > + Haptic Feedback + ); } diff --git a/src/features/settings/general/other/NoSubscribedInFeed.tsx b/src/features/settings/general/other/NoSubscribedInFeed.tsx index 86db743925..69fba8c988 100644 --- a/src/features/settings/general/other/NoSubscribedInFeed.tsx +++ b/src/features/settings/general/other/NoSubscribedInFeed.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { setNoSubscribedInFeed } from "../../settingsSlice"; @@ -11,11 +11,12 @@ export default function NoSubscribedInFeed() { return ( - No Subscribed in All/Local dispatch(setNoSubscribedInFeed(e.detail.checked))} - /> + > + No Subscribed in All/Local + ); } diff --git a/src/features/settings/general/posts/InfiniteScrolling.tsx b/src/features/settings/general/posts/InfiniteScrolling.tsx index 3ff2bfe8fe..96fc5ab638 100644 --- a/src/features/settings/general/posts/InfiniteScrolling.tsx +++ b/src/features/settings/general/posts/InfiniteScrolling.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { setInfiniteScrolling } from "../../settingsSlice"; @@ -11,11 +11,12 @@ export default function InfiniteScrolling() { return ( - Infinite Scrolling dispatch(setInfiniteScrolling(e.detail.checked))} - /> + > + Infinite Scrolling + ); } diff --git a/src/features/settings/general/posts/UpvoteOnSave.tsx b/src/features/settings/general/posts/UpvoteOnSave.tsx index e95e09b997..0e7f8921db 100644 --- a/src/features/settings/general/posts/UpvoteOnSave.tsx +++ b/src/features/settings/general/posts/UpvoteOnSave.tsx @@ -1,4 +1,4 @@ -import { IonLabel, IonToggle } from "@ionic/react"; +import { IonToggle } from "@ionic/react"; import { InsetIonItem } from "../../../../pages/profile/ProfileFeedItemsPage"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { setUpvoteOnSave } from "../../settingsSlice"; @@ -11,11 +11,12 @@ export default function UpvoteOnSave() { return ( - Upvote on Save dispatch(setUpvoteOnSave(e.detail.checked))} - /> + > + Upvote on Save + ); } diff --git a/src/features/settings/gestures/SwipeSettings.tsx b/src/features/settings/gestures/SwipeSettings.tsx index 61f1dfd1a5..143eca6560 100644 --- a/src/features/settings/gestures/SwipeSettings.tsx +++ b/src/features/settings/gestures/SwipeSettings.tsx @@ -106,22 +106,24 @@ export default function SwipeSettings() { - Disable Left Swipes dispatch(setDisableLeftSwipes(e.detail.checked)) } - /> + > + Disable Left Swipes + - Disable Right Swipes dispatch(setDisableRightSwipes(e.detail.checked)) } - /> + > + Disable Right Swipes + ` + ? styled(icon, { shouldForwardProp: (prop) => prop !== "mirror" })<{ + mirror?: boolean; + }>` position: relative; display: inline-flex; height: 4ex; From a24ca6131632b674e5513349dbea634d1319715f Mon Sep 17 00:00:00 2001 From: Alexander Harding <2166114+aeharding@users.noreply.github.com> Date: Sat, 2 Dec 2023 00:50:36 -0600 Subject: [PATCH 2/8] Add share post as image (#1000) * add modern-screenshot, remove html-to-image for better image support --- package.json | 2 +- pnpm-lock.yaml | 14 +- src/features/auth/PageContext.tsx | 18 +- src/features/comment/CommentEllipsis.tsx | 10 +- src/features/labels/links/CommunityLink.tsx | 8 +- src/features/labels/links/PersonLink.tsx | 11 +- src/features/labels/links/shared.ts | 12 + src/features/post/detail/PostDetail.tsx | 248 +---------------- src/features/post/detail/PostHeader.tsx | 256 +++++++++++++++++ .../post/inFeed/compact/CompactPost.tsx | 2 +- src/features/post/shared/MoreActions.tsx | 9 + src/features/share/asImage/ShareAsImage.tsx | 263 +++++++++++++----- .../share/asImage/ShareAsImageModal.tsx | 15 +- .../share/asImage/includeStyleProperties.ts | 8 + .../sidebar/internal/InstanceSidebar.tsx | 2 +- src/index.css | 1 - src/services/lemmy.ts | 7 +- src/services/nativeFetch.ts | 13 +- 18 files changed, 550 insertions(+), 349 deletions(-) create mode 100644 src/features/post/detail/PostHeader.tsx diff --git a/package.json b/package.json index d41ff1be1e..ccde42aac7 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "vite-express": "^0.11.0" }, "devDependencies": { + "@aeharding/modern-screenshot": "^4.5.0", "@capacitor-community/app-icon": "^4.1.1", "@capacitor/android": "5.2.3", "@capacitor/app": "^5.0.6", @@ -53,7 +54,6 @@ "@ionic/core": "npm:voyager-ionic-core@^7.5.5", "@ionic/react": "7.5.8-dev.11701383555.17254408", "@ionic/react-router": "7.5.8-dev.11701383555.17254408", - "@justfork/html-to-image": "^1.21.5", "@reduxjs/toolkit": "^1.9.7", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^14.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac017ea7d3..ccc012d47d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ dependencies: version: 0.11.0 devDependencies: + '@aeharding/modern-screenshot': + specifier: ^4.5.0 + version: 4.5.0 '@capacitor-community/app-icon': specifier: ^4.1.1 version: 4.1.1(@capacitor/core@5.2.3) @@ -88,9 +91,6 @@ devDependencies: '@ionic/react-router': specifier: 7.5.8-dev.11701383555.17254408 version: 7.5.8-dev.11701383555.17254408(react-dom@18.2.0)(react-router-dom@5.3.4)(react-router@5.3.4)(react@18.2.0) - '@justfork/html-to-image': - specifier: ^1.21.5 - version: 1.21.5 '@reduxjs/toolkit': specifier: ^1.9.7 version: 1.9.7(react-redux@8.1.3)(react@18.2.0) @@ -343,6 +343,10 @@ packages: resolution: {integrity: sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==} dev: true + /@aeharding/modern-screenshot@4.5.0: + resolution: {integrity: sha512-EEfz3JCF4/Nt2ynS/h4DKx5Gfg39J5F1I8yiFZIomzNThKCeiiSFl1k9D0HQBuT8pYmh30b6pPai1LDC+QhTeg==} + dev: true + /@ampproject/remapping@2.2.1: resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} engines: {node: '>=6.0.0'} @@ -2349,10 +2353,6 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true - /@justfork/html-to-image@1.21.5: - resolution: {integrity: sha512-07bImKz0XC7s285jSVzXU2Qas99oJznaE3c+baPltvONSKp/4tg8O8we4Zm4jxros8Hk6R+dcV1HhKWm++S+rw==} - dev: true - /@ljharb/through@2.3.11: resolution: {integrity: sha512-ccfcIDlogiXNq5KcbAwbaO7lMh3Tm1i3khMPYpxlK8hH/W53zN81KM9coerRLOnTGu3nfXIniAmQbRI9OxbC0w==} engines: {node: '>= 0.4'} diff --git a/src/features/auth/PageContext.tsx b/src/features/auth/PageContext.tsx index dcacbc2703..fba46ad3cb 100644 --- a/src/features/auth/PageContext.tsx +++ b/src/features/auth/PageContext.tsx @@ -54,7 +54,11 @@ interface IPageContext { presentSelectText: (text: string) => void; - presentShareAsImage: (comment: CommentView, comments: CommentView[]) => void; + presentShareAsImage: ( + post: PostView, + comment?: CommentView, + comments?: CommentView[], + ) => void; } export const PageContext = createContext({ @@ -100,11 +104,17 @@ export function PageContextProvider({ value, children }: PageContextProvider) { }, [jwt, presentLogin, value.pageRef]); const presentShareAsImage = useCallback( - (comment: CommentView, comments: CommentView[]) => { + (post: PostView, comment?: CommentView, comments?: CommentView[]) => { shareAsImageDataRef.current = { - comment, - comments, + post, }; + if (comment && comments) { + shareAsImageDataRef.current = { + ...shareAsImageDataRef.current, + comment, + comments, + }; + } presentShareAsImageModal({ cssClass: "save-as-image-modal", initialBreakpoint: 1, diff --git a/src/features/comment/CommentEllipsis.tsx b/src/features/comment/CommentEllipsis.tsx index 6125d73ea1..ff456187a8 100644 --- a/src/features/comment/CommentEllipsis.tsx +++ b/src/features/comment/CommentEllipsis.tsx @@ -78,6 +78,10 @@ export default function MoreActions({ const [presentSecondaryActionSheet] = useIonActionSheet(); const collapseRootComment = useCollapseRootComment(commentView, rootIndex); + const post = useAppSelector( + (state) => state.post.postById[commentView.post.id], + ); + const commentById = useAppSelector((state) => state.comment.commentById); const router = useOptimizedIonRouter(); @@ -272,8 +276,10 @@ export default function MoreActions({ icon: cameraOutline, handler: () => { const comments = getComments(); - if (!comments) return; - presentShareAsImage(commentView, comments); + + if (!comments || !post || post === "not-found") return; + + presentShareAsImage(post, commentView, comments); }, } : undefined, diff --git a/src/features/labels/links/CommunityLink.tsx b/src/features/labels/links/CommunityLink.tsx index 4faa1f878b..ed5d52da78 100644 --- a/src/features/labels/links/CommunityLink.tsx +++ b/src/features/labels/links/CommunityLink.tsx @@ -2,7 +2,7 @@ import { getHandle } from "../../../helpers/lemmy"; import { useBuildGeneralBrowseLink } from "../../../helpers/routes"; import { Community, SubscribedType } from "lemmy-js-client"; import Handle from "../Handle"; -import { StyledLink } from "./shared"; +import { StyledLink, hideCss } from "./shared"; import ItemIcon from "../img/ItemIcon"; import { css } from "@emotion/react"; import { useIonActionSheet } from "@ionic/react"; @@ -14,6 +14,8 @@ import { tabletPortraitOutline, } from "ionicons/icons"; import useCommunityActions from "../../community/useCommunityActions"; +import { useContext } from "react"; +import { ShareImageContext } from "../../share/asImage/ShareAsImage"; interface CommunityLinkProps { community: Community; @@ -34,6 +36,7 @@ export default function CommunityLink({ const [present] = useIonActionSheet(); const handle = getHandle(community); + const { hideCommunity } = useContext(ShareImageContext); const { isSubscribed, isBlocked, subscribe, block, sidebar } = useCommunityActions(community, subscribed); @@ -83,9 +86,10 @@ export default function CommunityLink({ to={buildGeneralBrowseLink(`/c/${handle}`)} onClick={(e) => e.stopPropagation()} className={className} + css={hideCommunity ? hideCss : undefined} {...bind()} > - {showIcon && ( + {showIcon && !hideCommunity && ( hideUsername && css` - position: relative; - - &:after { - content: ""; - position: absolute; - inset: 0; - background: var(--ion-color-step-150, #ccc); - } + ${hideCss} `} `; diff --git a/src/features/labels/links/shared.ts b/src/features/labels/links/shared.ts index bcac93ca37..cfd7b16379 100644 --- a/src/features/labels/links/shared.ts +++ b/src/features/labels/links/shared.ts @@ -1,3 +1,4 @@ +import { css } from "@emotion/react"; import styled from "@emotion/styled"; import { Link } from "react-router-dom"; @@ -7,3 +8,14 @@ export const StyledLink = styled(Link)` font-weight: 500; white-space: nowrap; `; + +export const hideCss = css` + position: relative; + + &:after { + content: ""; + position: absolute; + inset: 0; + background: var(--ion-color-step-150, #ccc); + } +`; diff --git a/src/features/post/detail/PostDetail.tsx b/src/features/post/detail/PostDetail.tsx index b96473a04f..7840b69470 100644 --- a/src/features/post/detail/PostDetail.tsx +++ b/src/features/post/detail/PostDetail.tsx @@ -1,124 +1,12 @@ -import { IonIcon, IonItem, IonSpinner, useIonViewDidEnter } from "@ionic/react"; +import { useIonViewDidEnter } from "@ionic/react"; import { useAppDispatch, useAppSelector } from "../../../store"; -import Stats from "./Stats"; -import styled from "@emotion/styled"; -import Embed from "../shared/Embed"; import Comments, { CommentsHandle } from "../../comment/Comments"; -import Markdown from "../../shared/Markdown"; -import PostActions from "../actions/PostActions"; -import { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { findLoneImage } from "../../../helpers/markdown"; +import { useCallback, useEffect, useRef, useState } from "react"; import { setPostRead } from "../postSlice"; -import { maxWidthCss } from "../../shared/AppContent"; -import PersonLink from "../../labels/links/PersonLink"; import { CommentSortType, PostView } from "lemmy-js-client"; import ViewAllComments from "./ViewAllComments"; -import InlineMarkdown from "../../shared/InlineMarkdown"; -import { megaphone } from "ionicons/icons"; -import CommunityLink from "../../labels/links/CommunityLink"; -import { css } from "@emotion/react"; -import Nsfw, { isNsfw } from "../../labels/Nsfw"; -import { PageContext } from "../../auth/PageContext"; -import PostMedia from "../../gallery/PostMedia"; -import { scrollIntoView } from "../../../helpers/dom"; import JumpFab from "../../comment/JumpFab"; -import { OTapToCollapseType } from "../../../services/db"; -import Locked from "./Locked"; -import useAppToast from "../../../helpers/useAppToast"; -import { postLocked } from "../../../helpers/toastMessages"; -import { isUrlMedia } from "../../../helpers/url"; -import ModeratableItem, { - ModeratableItemBannerOutlet, -} from "../../moderation/ModeratableItem"; - -const BorderlessIonItem = styled(IonItem)` - --padding-start: 0; - --inner-padding-end: 0; - - --inner-border-width: 0 0 1px 0; - --background: none; // TODO is this OK? - - ${maxWidthCss} -`; - -export const CenteredSpinner = styled(IonSpinner)` - position: relative; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); -`; - -const Container = styled.div` - width: 100%; -`; - -const lightboxCss = css` - width: 100%; - max-height: 50vh; - object-fit: contain; - background: var(--lightroom-bg); -`; - -const LightboxPostMedia = styled(PostMedia)` - -webkit-touch-callout: default; - - ${lightboxCss} -`; - -const StyledMarkdown = styled(Markdown)` - margin: 12px 0; - - img { - display: block; - max-width: 100%; - max-height: 50vh; - object-fit: contain; - object-position: 0%; - } -`; - -const StyledEmbed = styled(Embed)` - margin: 12px 0; -`; - -const PostDeets = styled.div` - margin: 12px; - font-size: 0.9375em; - - display: flex; - flex-direction: column; - gap: 12px; -`; - -const Title = styled.div` - font-size: 1.125rem; - margin-bottom: 12px; -`; - -const By = styled.div` - font-size: 0.875em; - - margin-bottom: 5px; - color: var(--ion-color-text-aside); - - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`; - -export const AnnouncementIcon = styled(IonIcon)` - font-size: 1.1rem; - margin-right: 5px; - vertical-align: middle; - color: var(--ion-color-success); -`; +import PostHeader from "./PostHeader"; interface PostDetailProps { post: PostView; @@ -134,23 +22,12 @@ export default function PostDetail({ commentPath, threadCommentId, }: PostDetailProps) { - const [collapsed, setCollapsed] = useState(false); const dispatch = useAppDispatch(); - const markdownLoneImage = useMemo( - () => (post?.post.body ? findLoneImage(post.post.body) : undefined), - [post], - ); const { showJumpButton, jumpButtonPosition } = useAppSelector( (state) => state.settings.general.comments, ); - const titleRef = useRef(null); - const { presentLoginIfNeeded, presentCommentReply } = useContext(PageContext); const [ionViewEntered, setIonViewEntered] = useState(false); const commentsRef = useRef(null); - const { tapToCollapse } = useAppSelector( - (state) => state.settings.general.comments, - ); - const presentToast = useAppToast(); const [viewAllCommentsSpace, setViewAllCommentsSpace] = useState(70); // px @@ -167,121 +44,11 @@ export default function PostDetail({ setIonViewEntered(true); }); - useEffect(() => { - if (!titleRef.current) return; - - scrollIntoView(titleRef.current); - }, [collapsed]); - const onHeight = useCallback( (height: number) => setViewAllCommentsSpace(height), [], ); - const renderMedia = useCallback(() => { - if (!post) return; - - if ((post.post.url && isUrlMedia(post.post.url)) || markdownLoneImage) { - return ; - } - }, [markdownLoneImage, post]); - - const renderText = useCallback(() => { - if (!post) return; - - const usedLoneImage = - markdownLoneImage && (!post.post.url || !isUrlMedia(post.post.url)); - - if (post.post.body && !usedLoneImage) { - return ( - <> - {post.post.url && !isUrlMedia(post.post.url) && } - {post.post.body} - > - ); - } - - if (post.post.url && !isUrlMedia(post.post.url)) { - return ; - } - }, [markdownLoneImage, post]); - - const renderHeader = useCallback( - (post: PostView) => { - return ( - - { - if (e.target instanceof HTMLElement && e.target.nodeName === "A") - return; - - if ( - tapToCollapse === OTapToCollapseType.Neither || - tapToCollapse === OTapToCollapseType.OnlyComments - ) - return; - - setCollapsed(!collapsed); - }} - > - - e.stopPropagation()}>{renderMedia()} - - - - - {post.post.name}{" "} - {isNsfw(post) && } - - {!collapsed && renderText()} - - {post.post.featured_community || - post.post.featured_local ? ( - - ) : undefined} - {" "} - - - - {post.post.locked && } - - - - - - { - if (presentLoginIfNeeded()) return; - if (post.post.locked) { - presentToast(postLocked); - return; - } - - const reply = await presentCommentReply(post); - - if (reply) commentsRef.current?.prependComments([reply]); - }} - /> - - - ); - }, - [ - collapsed, - presentCommentReply, - presentLoginIfNeeded, - presentToast, - renderMedia, - renderText, - tapToCollapse, - ], - ); - const bottomPadding: number = (() => { if (commentPath) return viewAllCommentsSpace + 12; @@ -300,7 +67,14 @@ export default function PostDetail({ <> + commentsRef.current?.prependComments([comment]) + } + /> + } postId={post.post.id} commentPath={commentPath} threadCommentId={threadCommentId} diff --git a/src/features/post/detail/PostHeader.tsx b/src/features/post/detail/PostHeader.tsx new file mode 100644 index 0000000000..d01530da15 --- /dev/null +++ b/src/features/post/detail/PostHeader.tsx @@ -0,0 +1,256 @@ +import styled from "@emotion/styled"; +import { IonIcon, IonItem } from "@ionic/react"; +import { CommentView, PostView } from "lemmy-js-client"; +import { maxWidthCss } from "../../shared/AppContent"; +import ModeratableItem, { + ModeratableItemBannerOutlet, +} from "../../moderation/ModeratableItem"; +import { OTapToCollapseType } from "../../../services/db"; +import { + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { PageContext } from "../../auth/PageContext"; +import { scrollIntoView } from "../../../helpers/dom"; +import { useAppSelector } from "../../../store"; +import useAppToast from "../../../helpers/useAppToast"; +import { findLoneImage } from "../../../helpers/markdown"; +import { isUrlMedia } from "../../../helpers/url"; +import { css } from "@emotion/react"; +import PostMedia from "../../gallery/PostMedia"; +import Markdown from "../../shared/Markdown"; +import Embed from "../shared/Embed"; +import InlineMarkdown from "../../shared/InlineMarkdown"; +import Nsfw, { isNsfw } from "../../labels/Nsfw"; +import { megaphone } from "ionicons/icons"; +import CommunityLink from "../../labels/links/CommunityLink"; +import PersonLink from "../../labels/links/PersonLink"; +import Stats from "./Stats"; +import Locked from "./Locked"; +import PostActions from "../actions/PostActions"; +import { postLocked } from "../../../helpers/toastMessages"; + +const BorderlessIonItem = styled(IonItem)` + --padding-start: 0; + --inner-padding-end: 0; + + --inner-border-width: 0 0 1px 0; + --background: none; // TODO is this OK? + + ${maxWidthCss} +`; + +const LightboxPostMedia = styled(PostMedia)<{ constrainHeight?: boolean }>` + -webkit-touch-callout: default; + + width: 100%; + object-fit: contain; + background: var(--lightroom-bg); + + ${({ constrainHeight }) => + constrainHeight && + css` + max-height: 50vh; + `} +`; + +const StyledMarkdown = styled(Markdown)` + margin: 12px 0; + + img { + display: block; + max-width: 100%; + max-height: 50vh; + object-fit: contain; + object-position: 0%; + } +`; + +const StyledEmbed = styled(Embed)` + margin: 12px 0; +`; + +const Container = styled.div` + width: 100%; +`; + +const PostDeets = styled.div` + margin: 12px; + font-size: 0.9375em; + + display: flex; + flex-direction: column; + gap: 12px; +`; + +export const AnnouncementIcon = styled(IonIcon)` + font-size: 1.1rem; + margin-right: 5px; + vertical-align: middle; + color: var(--ion-color-success); +`; + +const Title = styled.div` + font-size: 1.125rem; + margin-bottom: 12px; +`; + +const By = styled.div` + font-size: 0.875em; + + margin-bottom: 5px; + color: var(--ion-color-text-aside); + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +interface PostHeaderProps { + post: PostView; + onPrependComment?: (comment: CommentView) => void; + + // For Share as Image + showPostActions?: boolean; + showPostText?: boolean; + constrainHeight?: boolean; + + className?: string; +} + +function PostHeader({ + post, + onPrependComment, + showPostActions = true, + showPostText = true, + constrainHeight = true, + className, +}: PostHeaderProps) { + const [collapsed, setCollapsed] = useState(false); + const titleRef = useRef(null); + const { presentLoginIfNeeded, presentCommentReply } = useContext(PageContext); + + const tapToCollapse = useAppSelector( + (state) => state.settings.general.comments.tapToCollapse, + ); + const presentToast = useAppToast(); + + useEffect(() => { + if (!titleRef.current) return; + + scrollIntoView(titleRef.current); + }, [collapsed]); + + const markdownLoneImage = useMemo( + () => (post?.post.body ? findLoneImage(post.post.body) : undefined), + [post], + ); + + const renderMedia = useCallback(() => { + if (!post) return; + + if ((post.post.url && isUrlMedia(post.post.url)) || markdownLoneImage) { + return ( + + ); + } + }, [markdownLoneImage, post, constrainHeight]); + + const renderText = useCallback(() => { + if (!post) return; + + const usedLoneImage = + markdownLoneImage && (!post.post.url || !isUrlMedia(post.post.url)); + + if (post.post.body && !usedLoneImage) { + return ( + <> + {post.post.url && !isUrlMedia(post.post.url) && } + {post.post.body} + > + ); + } + + if (post.post.url && !isUrlMedia(post.post.url)) { + return ; + } + }, [markdownLoneImage, post]); + + return ( + + { + if (e.target instanceof HTMLElement && e.target.nodeName === "A") + return; + + if ( + tapToCollapse === OTapToCollapseType.Neither || + tapToCollapse === OTapToCollapseType.OnlyComments + ) + return; + + setCollapsed(!collapsed); + }} + > + + {showPostText && ( + e.stopPropagation()}>{renderMedia()} + )} + + + + + {post.post.name}{" "} + {isNsfw(post) && } + + {!collapsed && showPostText && renderText()} + + {post.post.featured_community || post.post.featured_local ? ( + + ) : undefined} + {" "} + + + + {post.post.locked && } + + + + + {showPostActions && ( + + { + if (presentLoginIfNeeded()) return; + if (post.post.locked) { + presentToast(postLocked); + return; + } + + const reply = await presentCommentReply(post); + + if (reply) onPrependComment?.(reply); + }} + /> + + )} + + ); +} + +export default memo(PostHeader); diff --git a/src/features/post/inFeed/compact/CompactPost.tsx b/src/features/post/inFeed/compact/CompactPost.tsx index 67262f0a30..0a04533da9 100644 --- a/src/features/post/inFeed/compact/CompactPost.tsx +++ b/src/features/post/inFeed/compact/CompactPost.tsx @@ -7,7 +7,6 @@ import PreviewStats from "../PreviewStats"; import MoreActions from "../../shared/MoreActions"; import { megaphone } from "ionicons/icons"; import PersonLink from "../../../labels/links/PersonLink"; -import { AnnouncementIcon } from "../../detail/PostDetail"; import CommunityLink from "../../../labels/links/CommunityLink"; import { VoteButton } from "../../shared/VoteButton"; import Save from "../../../labels/Save"; @@ -20,6 +19,7 @@ import ModeratableItem, { ModeratableItemBannerOutlet, } from "../../../moderation/ModeratableItem"; import ModqueueItemActions from "../../../moderation/ModqueueItemActions"; +import { AnnouncementIcon } from "../../detail/PostHeader"; const Container = styled.div` width: 100%; diff --git a/src/features/post/shared/MoreActions.tsx b/src/features/post/shared/MoreActions.tsx index eeea7874df..9f5ef42ef6 100644 --- a/src/features/post/shared/MoreActions.tsx +++ b/src/features/post/shared/MoreActions.tsx @@ -4,6 +4,7 @@ import { arrowUndoOutline, arrowUpOutline, bookmarkOutline, + cameraOutline, ellipsisHorizontal, eyeOffOutline, eyeOutline, @@ -74,6 +75,7 @@ export default function MoreActions({ presentReport, presentPostEditor, presentSelectText, + presentShareAsImage, } = useContext(PageContext); const presentPostModActions = usePostModActions(post); @@ -259,6 +261,13 @@ export default function MoreActions({ share(post.post); }, }, + { + text: "Share as image...", + icon: cameraOutline, + handler: () => { + presentShareAsImage(post); + }, + }, { text: "Report", data: "report", diff --git a/src/features/share/asImage/ShareAsImage.tsx b/src/features/share/asImage/ShareAsImage.tsx index 7cb030fde4..a458bc65de 100644 --- a/src/features/share/asImage/ShareAsImage.tsx +++ b/src/features/share/asImage/ShareAsImage.tsx @@ -1,7 +1,13 @@ import { IonButton, IonItem, IonLabel, IonList, IonToggle } from "@ionic/react"; -import { CommentView } from "lemmy-js-client"; -import { ReactNode, createContext, useEffect, useMemo, useState } from "react"; -import { toBlob } from "@justfork/html-to-image"; +import { + ReactNode, + createContext, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from "react"; import { createPortal } from "react-dom"; import CommentTree from "../../comment/CommentTree"; import { buildCommentsTree, getDepthFromComment } from "../../../helpers/lemmy"; @@ -15,6 +21,12 @@ import { Filesystem, Directory } from "@capacitor/filesystem"; import { blobToDataURL, blobToString } from "../../../helpers/blob"; import useAppToast from "../../../helpers/useAppToast"; import includeStyleProperties from "./includeStyleProperties"; +import { CapacitorHttp } from "@capacitor/core"; +import { domToBlob } from "@aeharding/modern-screenshot"; +import { getImageSrc } from "../../../services/lemmy"; +import { ShareAsImageData } from "./ShareAsImageModal"; +import PostHeader from "../../post/detail/PostHeader"; +import { webviewServerUrl } from "../../../services/nativeFetch"; const Container = styled.div` --bottom-padding: max( @@ -63,10 +75,12 @@ const sharedImgCss = css` `; const PlaceholderImg = styled.div` + ${sharedImgCss} + + background: ${({ theme }) => (theme.dark ? "black" : "white")}; + height: 80px; width: 80%; - - ${sharedImgCss} `; const PreviewImg = styled.img` @@ -90,29 +104,47 @@ const CommentSnapshotContainer = styled.div` background: var(--ion-item-background, var(--ion-background-color, #fff)); `; +const PostCommentSpacer = styled.div` + height: 6px; +`; + +const StyledPostHeader = styled(PostHeader)<{ hideBottomBorder: boolean }>` + ${({ hideBottomBorder }) => + hideBottomBorder && + css` + --inner-border-width: 0 0 0 0; + `} +`; + const shareAsImageRenderRoot = document.querySelector( "#share-as-image-root", ) as HTMLElement; interface ShareAsImageProps { - data: { - comment: CommentView; - comments: CommentView[]; - }; + data: ShareAsImageData; header: ReactNode; } export default function ShareAsImage({ data, header }: ShareAsImageProps) { const presentToast = useAppToast(); - const [blob, setBlob] = useState(); - const [minDepth, setMinDepth] = useState( - getDepthFromComment(data.comment.comment) ?? 0, - ); + const [hideUsernames, setHideUsernames] = useState(false); + const [hideCommunity, setHideCommunity] = useState(false); + const [includePostDetails, setIncludePostDetails] = useState( + !("comment" in data), + ); + const [includePostText, setIncludePostText] = useState(true); const [watermark, setWatermark] = useState(false); + const [blob, setBlob] = useState(); const [imageSrc, setImageSrc] = useState(""); + const [minDepth, setMinDepth] = useState( + ("comment" in data + ? getDepthFromComment(data.comment.comment) + : undefined) ?? 0, + ); + useEffect(() => { if (!blob) return; @@ -122,6 +154,8 @@ export default function ShareAsImage({ data, header }: ShareAsImageProps) { }, [blob]); const filteredComments = useMemo(() => { + if (!("comment" in data)) return []; + const filtered = data.comments .filter( (c) => @@ -136,53 +170,89 @@ export default function ShareAsImage({ data, header }: ShareAsImageProps) { return filtered; }, [data, minDepth]); + const commentNode = useMemo( - () => buildCommentsTree(filteredComments, true), + () => + filteredComments.length ? buildCommentsTree(filteredComments, true) : [], [filteredComments], ); - useEffect(() => { - setTimeout(async () => { - try { - const blob = await toBlob( - shareAsImageRenderRoot.querySelector(".inner") as HTMLElement, - { - pixelRatio: 4, - includeStyleProperties, - - // TODO, for now ignore image/video to avoid tainted canvas failing render - // (there's also display: none for img/video in index.css) - // - // Two ways around this in the future: - // - // 1. Use a centralized proxy for this - // 2. Patch html-to-image to get image data using fetch API (native-only) - filter: (node) => { - if (node.tagName === "IMG") { - if (node.classList.contains("allowed-image")) return true; - - return false; - } + const render = useCallback(async () => { + try { + const blob = await domToBlob( + shareAsImageRenderRoot.querySelector(".inner") as HTMLElement, + { + scale: 4, + features: { + // Without this, render fails on certain images + removeControlCharacter: false, + }, + includeStyleProperties, + filter: (node) => { + if (!(node instanceof HTMLElement)) return true; - return node.tagName !== "VIDEO"; - }, + return node.tagName !== "VIDEO"; }, - ); - setBlob(blob ?? undefined); - } catch (error) { - presentToast({ - message: "Error rendering image.", - }); - - throw error; - } - }, 200); - }, [data, filteredComments, watermark, hideUsernames, presentToast]); + fetchFn: isNative() + ? async (url) => { + // Pass through relative URLs to browser fetching + if (url.startsWith(`${webviewServerUrl}/`)) { + return false; + } + + // Attempt upgrade to https (insecure will be blocked) + if (url.startsWith("http://")) { + url = url.replace(/^http:\/\//, "https://"); + } + + const nativeResponse = await CapacitorHttp.get({ + // if pictrs, convert large gifs to jpg + url: getImageSrc(url, { format: "jpg" }), + responseType: "blob", + }); + + // Workaround that will probably break in a future capacitor upgrade + // https://github.com/ionic-team/capacitor/issues/6126 + return `data:${ + nativeResponse.headers["Content-Type"] || "image/png" + };base64,${nativeResponse.data}`; + } + : undefined, + }, + ); + setBlob(blob ?? undefined); + } catch (error) { + presentToast({ + message: "Error rendering image.", + }); + + throw error; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [presentToast]); + + useLayoutEffect(() => { + requestAnimationFrame(() => { + render(); + }); + }, [ + render, + data, + filteredComments, + watermark, + hideUsernames, + hideCommunity, + includePostDetails, + includePostText, + ]); async function onShare() { if (!blob) return; - const filename = `${data.comment.comment.ap_id + const apId = + "comment" in data ? data.comment.comment.ap_id : data.post.post.ap_id; + + const filename = `${apId .replace(/^https:\/\//, "") .replaceAll(/\//g, "-")}.png`; @@ -238,22 +308,56 @@ export default function ShareAsImage({ data, header }: ShareAsImageProps) { )} - {!!getDepthFromComment(data.comment.comment) && ( + {"comment" in data && ( + <> + + setIncludePostDetails(e.detail.checked)} + > + Include Post Details + + + {includePostDetails && ( + + setIncludePostText(e.detail.checked)} + > + Include Post Text + + + )} + + {!!getDepthFromComment(data.comment.comment) && ( + + Parent Comments + + + {(getDepthFromComment(data.comment.comment) ?? 0) - + minDepth} + + setMinDepth((minDepth) => minDepth - 1)} + onRemove={() => setMinDepth((minDepth) => minDepth + 1)} + /> + + + )} + > + )} + {includePostDetails && ( - Parent Comments - - - {(getDepthFromComment(data.comment.comment) ?? 0) - minDepth} - - setMinDepth((minDepth) => minDepth - 1)} - onRemove={() => setMinDepth((minDepth) => minDepth + 1)} - /> - + setHideCommunity(e.detail.checked)} + > + Hide Community + )} @@ -279,13 +383,27 @@ export default function ShareAsImage({ data, header }: ShareAsImageProps) { {createPortal( - - + + {includePostDetails && ( + + )} + {"comment" in data && ( + <> + {includePostDetails && } + + > + )} {watermark && } , @@ -297,4 +415,5 @@ export default function ShareAsImage({ data, header }: ShareAsImageProps) { export const ShareImageContext = createContext({ hideUsernames: false, + hideCommunity: false, }); diff --git a/src/features/share/asImage/ShareAsImageModal.tsx b/src/features/share/asImage/ShareAsImageModal.tsx index aaf30ab452..fd22036735 100644 --- a/src/features/share/asImage/ShareAsImageModal.tsx +++ b/src/features/share/asImage/ShareAsImageModal.tsx @@ -1,4 +1,4 @@ -import { CommentView } from "lemmy-js-client"; +import { CommentView, PostView } from "lemmy-js-client"; import ShareAsImage from "./ShareAsImage"; import { MutableRefObject, useEffect, useState } from "react"; import styled from "@emotion/styled"; @@ -9,10 +9,15 @@ import { } from "../../shared/selectorModals/GenericSelectorModal"; import { close } from "ionicons/icons"; -export interface ShareAsImageData { - comment: CommentView; - comments: CommentView[]; -} +export type ShareAsImageData = + | { + post: PostView; + } + | { + post: PostView; + comment: CommentView; + comments: CommentView[]; + }; interface SelectTextProps { dataRef: MutableRefObject; diff --git a/src/features/share/asImage/includeStyleProperties.ts b/src/features/share/asImage/includeStyleProperties.ts index 48c17978a3..fceb11a84c 100644 --- a/src/features/share/asImage/includeStyleProperties.ts +++ b/src/features/share/asImage/includeStyleProperties.ts @@ -14,6 +14,7 @@ export default [ "font-family", "fill", "stroke", + "stroke-width", "margin", "padding", "padding-left", @@ -34,6 +35,9 @@ export default [ "opacity", "border-right", "border-left", + "border-width", + "border-style", + "border-color", "margin-right", "margin-left", "margin-top", @@ -43,10 +47,14 @@ export default [ "text-overflow", "font-weight", "min-width", + "min-height", "transform", "z-index", "flex", "border-width", "text-wrap", "word-break", + "vertical-align", + "aspect-ratio", + "object-fit", ]; diff --git a/src/features/sidebar/internal/InstanceSidebar.tsx b/src/features/sidebar/internal/InstanceSidebar.tsx index 33bcb352ed..c6f616c402 100644 --- a/src/features/sidebar/internal/InstanceSidebar.tsx +++ b/src/features/sidebar/internal/InstanceSidebar.tsx @@ -1,9 +1,9 @@ import { css } from "@emotion/react"; import { useAppSelector } from "../../../store"; -import { CenteredSpinner } from "../../post/detail/PostDetail"; import GenericSidebar from "./GenericSidebar"; import { IonBadge } from "@ionic/react"; import { lemmyVersionSelector } from "../../auth/authSlice"; +import { CenteredSpinner } from "../../../pages/posts/PostPage"; export default function InstanceSidebar() { const siteView = useAppSelector((state) => state.auth.site?.site_view); diff --git a/src/index.css b/src/index.css index b303ada152..4b2c729c36 100644 --- a/src/index.css +++ b/src/index.css @@ -262,7 +262,6 @@ ion-modal.save-as-image-modal { --max-width: 470px; } -#share-as-image-root img:not(.allowed-image), #share-as-image-root video { display: none; } diff --git a/src/services/lemmy.ts b/src/services/lemmy.ts index b3a749df1a..6477b12d60 100644 --- a/src/services/lemmy.ts +++ b/src/services/lemmy.ts @@ -136,6 +136,8 @@ interface ImageOptions { */ size?: number; + devicePixelRatio?: number; + format?: "jpg" | "png" | "webp"; } @@ -148,7 +150,10 @@ export function getImageSrc(url: string, options?: ImageOptions) { ? new URLSearchParams( omitUndefinedValues({ thumbnail: options.size - ? `${Math.round(options.size * window.devicePixelRatio)}` + ? `${Math.round( + options.size * + (options?.devicePixelRatio ?? window.devicePixelRatio), + )}` : undefined, format: options.format ?? defaultFormat, }), diff --git a/src/services/nativeFetch.ts b/src/services/nativeFetch.ts index 1c24f523a1..4693357a1b 100644 --- a/src/services/nativeFetch.ts +++ b/src/services/nativeFetch.ts @@ -5,16 +5,17 @@ import { CapFormDataEntry } from "@capacitor/core/types/definitions-internal"; // Stolen from capacitor fetch shim // https://github.com/ionic-team/capacitor/blob/5.2.3/core/native-bridge.ts +export const webviewServerUrl = + "WEBVIEW_SERVER_URL" in window && + typeof window.WEBVIEW_SERVER_URL === "string" + ? window.WEBVIEW_SERVER_URL + : ""; + export default async function nativeFetch( resource: RequestInfo | URL, options?: RequestInit, ) { - if ( - !( - resource.toString().startsWith("http:") || - resource.toString().startsWith("https:") - ) - ) { + if (resource.toString().startsWith(`${webviewServerUrl}/`)) { return window.fetch(resource, options); } From 00c6e9dcf4d778e48c4433c14cc2dcb503d6c745 Mon Sep 17 00:00:00 2001 From: Alexander Harding <2166114+aeharding@users.noreply.github.com> Date: Sat, 2 Dec 2023 00:50:52 -0600 Subject: [PATCH 3/8] Add fallback for firefox share as image (#1002) --- src/features/share/asImage/ShareAsImage.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/features/share/asImage/ShareAsImage.tsx b/src/features/share/asImage/ShareAsImage.tsx index a458bc65de..ba99fc1237 100644 --- a/src/features/share/asImage/ShareAsImage.tsx +++ b/src/features/share/asImage/ShareAsImage.tsx @@ -260,6 +260,9 @@ export default function ShareAsImage({ data, header }: ShareAsImageProps) { type: "image/png", }); + // eslint-disable-next-line no-undef + const webSharePayload: ShareData = { files: [file] }; + if (isNative()) { const data = await blobToString(blob); const file = await Filesystem.writeFile({ @@ -269,8 +272,8 @@ export default function ShareAsImage({ data, header }: ShareAsImageProps) { }); await Share.share({ files: [file.uri] }); await Filesystem.deleteFile({ path: file.uri }); - } else if ("share" in navigator) { - navigator.share({ files: [file] }); + } else if ("canShare" in navigator && navigator.canShare(webSharePayload)) { + navigator.share(webSharePayload); } else { const link = document.createElement("a"); link.download = filename; @@ -378,7 +381,7 @@ export default function ShareAsImage({ data, header }: ShareAsImageProps) { - {isNative() || "share" in navigator ? "Share" : "Download"} + {isNative() || "canShare" in navigator ? "Share" : "Download"} {createPortal( From 13672ab82eaaff84866985c7ae49cc0083d71d34 Mon Sep 17 00:00:00 2001 From: Alexander Harding <2166114+aeharding@users.noreply.github.com> Date: Sat, 2 Dec 2023 00:51:07 -0600 Subject: [PATCH 4/8] Refactor share as image css (#1001) --- src/features/share/asImage/ShareAsImage.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/features/share/asImage/ShareAsImage.tsx b/src/features/share/asImage/ShareAsImage.tsx index ba99fc1237..a83392fcc0 100644 --- a/src/features/share/asImage/ShareAsImage.tsx +++ b/src/features/share/asImage/ShareAsImage.tsx @@ -41,27 +41,22 @@ const Container = styled.div` } display: grid; - grid-template-columns: 100%; - grid-template-rows: auto 1fr auto; + grid-template-rows: max-content 1fr max-content; + max-height: calc( 100vh - var(--ion-safe-area-top, env(safe-area-inset-top, 0)) - var( --top-space ) ); - place-content: center; padding: 0 16px var(--bottom-padding); `; const sharedImgCss = css` - max-height: 100%; min-height: 0; - height: auto; + max-height: 100%; + justify-self: center; max-width: 100%; - min-width: 0; - width: auto; - vertical-align: middle; - margin: auto; filter: var(--share-img-drop-shadow); From ead1937dfd640c473bdb4e33b616c96beaf88e76 Mon Sep 17 00:00:00 2001 From: Alexander Harding <2166114+aeharding@users.noreply.github.com> Date: Sat, 2 Dec 2023 13:27:57 -0600 Subject: [PATCH 5/8] Fix highlighted comment scroll glitch (#1003) --- src/features/post/detail/PostHeader.tsx | 25 +++++++++++-------------- src/features/post/postSlice.ts | 7 +++++++ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/features/post/detail/PostHeader.tsx b/src/features/post/detail/PostHeader.tsx index d01530da15..d055b368e4 100644 --- a/src/features/post/detail/PostHeader.tsx +++ b/src/features/post/detail/PostHeader.tsx @@ -6,18 +6,10 @@ import ModeratableItem, { ModeratableItemBannerOutlet, } from "../../moderation/ModeratableItem"; import { OTapToCollapseType } from "../../../services/db"; -import { - memo, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { memo, useCallback, useContext, useMemo, useRef } from "react"; import { PageContext } from "../../auth/PageContext"; import { scrollIntoView } from "../../../helpers/dom"; -import { useAppSelector } from "../../../store"; +import { useAppDispatch, useAppSelector } from "../../../store"; import useAppToast from "../../../helpers/useAppToast"; import { findLoneImage } from "../../../helpers/markdown"; import { isUrlMedia } from "../../../helpers/url"; @@ -34,6 +26,7 @@ import Stats from "./Stats"; import Locked from "./Locked"; import PostActions from "../actions/PostActions"; import { postLocked } from "../../../helpers/toastMessages"; +import { togglePostCollapse } from "../postSlice"; const BorderlessIonItem = styled(IonItem)` --padding-start: 0; @@ -131,7 +124,10 @@ function PostHeader({ constrainHeight = true, className, }: PostHeaderProps) { - const [collapsed, setCollapsed] = useState(false); + const dispatch = useAppDispatch(); + const collapsed = useAppSelector( + (state) => !!state.post.postCollapsedById[post.post.id], + ); const titleRef = useRef(null); const { presentLoginIfNeeded, presentCommentReply } = useContext(PageContext); @@ -140,11 +136,11 @@ function PostHeader({ ); const presentToast = useAppToast(); - useEffect(() => { + const scrollTitleIntoView = useCallback(() => { if (!titleRef.current) return; scrollIntoView(titleRef.current); - }, [collapsed]); + }, []); const markdownLoneImage = useMemo( () => (post?.post.body ? findLoneImage(post.post.body) : undefined), @@ -199,7 +195,8 @@ function PostHeader({ ) return; - setCollapsed(!collapsed); + dispatch(togglePostCollapse(post.post.id)); + scrollTitleIntoView(); }} > diff --git a/src/features/post/postSlice.ts b/src/features/post/postSlice.ts index 12e29ffaf0..049793f892 100644 --- a/src/features/post/postSlice.ts +++ b/src/features/post/postSlice.ts @@ -43,6 +43,7 @@ interface PostState { postVotesById: Dictionary<1 | -1 | 0>; postSavedById: Dictionary; postReadById: Dictionary; + postCollapsedById: Dictionary; sort: SortType; } @@ -53,6 +54,7 @@ const initialState: PostState = { postVotesById: {}, postSavedById: {}, postReadById: {}, + postCollapsedById: {}, sort: get(POST_SORT_KEY) ?? POST_SORTS[0], }; @@ -86,6 +88,10 @@ export const postSlice = createSlice({ postDeleted: (state, action: PayloadAction) => { state.postDeletedById[action.payload] = true; }, + togglePostCollapse: (state, action: PayloadAction) => { + state.postCollapsedById[action.payload] = + !state.postCollapsedById[action.payload]; + }, resetHidden: (state) => { state.postHiddenById = {}; }, @@ -234,6 +240,7 @@ export const { updatePostRead, receivedPostNotFound, postDeleted, + togglePostCollapse, resetHidden, } = postSlice.actions; From 97bb245f0cdec4eb27ce2719e7ce86b6c1e2cadd Mon Sep 17 00:00:00 2001 From: Alexander Harding <2166114+aeharding@users.noreply.github.com> Date: Sat, 2 Dec 2023 15:02:37 -0600 Subject: [PATCH 6/8] Add ability to always swipe back to communities list (#1004) --- src/TabbedRoutes.tsx | 18 ++-- .../CommunitiesListRedirectBootstrapper.tsx | 92 +++++++++++++++++++ src/pages/posts/CommunitiesPage.tsx | 30 +++--- src/services/app.ts | 2 +- 4 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 src/features/community/list/CommunitiesListRedirectBootstrapper.tsx diff --git a/src/TabbedRoutes.tsx b/src/TabbedRoutes.tsx index be0b6aedb4..d3dff6a0ad 100644 --- a/src/TabbedRoutes.tsx +++ b/src/TabbedRoutes.tsx @@ -51,6 +51,8 @@ import CommentsPage from "./pages/shared/CommentsPage"; import ModlogPage from "./pages/shared/ModlogPage"; import ModqueuePage from "./pages/shared/ModqueuePage"; import TabBar from "./TabBar"; +import { isInstalled } from "./helpers/device"; +import { getBaseRoute } from "./features/community/list/CommunitiesListRedirectBootstrapper"; export default function TabbedRoutes() { const ready = useAppSelector((state) => state.settings.ready); @@ -196,6 +198,12 @@ export default function TabbedRoutes() { if (!ready) return; + const redirectRoute = (() => { + if (isInstalled()) return ""; // redirect to be handled by + + return getBaseRoute(!!iss, defaultFeed); + })(); + return ( @@ -207,13 +215,7 @@ export default function TabbedRoutes() { {!iss || defaultFeed ? ( ) : ( @@ -377,7 +379,7 @@ export default function TabbedRoutes() { ); } -function getPathForFeed(defaultFeed: DefaultFeedType): string { +export function getPathForFeed(defaultFeed: DefaultFeedType): string { switch (defaultFeed.type) { case ODefaultFeedType.All: return "/all"; diff --git a/src/features/community/list/CommunitiesListRedirectBootstrapper.tsx b/src/features/community/list/CommunitiesListRedirectBootstrapper.tsx new file mode 100644 index 0000000000..f958364295 --- /dev/null +++ b/src/features/community/list/CommunitiesListRedirectBootstrapper.tsx @@ -0,0 +1,92 @@ +import { useAppSelector } from "../../../store"; +import { jwtIssSelector } from "../../auth/authSlice"; +import { useRef, useState } from "react"; +import { + TransitionOptions, + createAnimation, + iosTransitionAnimation, + mdTransitionAnimation, + useIonViewDidEnter, +} from "@ionic/react"; +import { getPathForFeed } from "../../../TabbedRoutes"; +import { DefaultFeedType, ODefaultFeedType } from "../../../services/db"; +import { useBuildGeneralBrowseLink } from "../../../helpers/routes"; +import { isInstalled } from "../../../helpers/device"; +import styled from "@emotion/styled"; +import { useOptimizedIonRouter } from "../../../helpers/useOptimizedIonRouter"; + +const LoadingOverlay = styled.div` + background: var(--ion-background-color); + position: fixed; + inset: 0; + z-index: 1000; +`; + +/** + * This component redirects after the Communities List is mounted, + * for installed apps only. + * + * This improves user experience by always allowing swipe back. + * + * Note: This will become unecessary with the resolution of + * https://github.com/ionic-team/ionic-framework/issues/27892 + */ +export default function CommunitiesListRedirectBootstrapper() { + const buildGeneralBrowseLink = useBuildGeneralBrowseLink(); + const router = useOptimizedIonRouter(); + const [bootstrapped, setBootstrapped] = useState(false); + + const defaultFeed = useAppSelector( + (state) => state.settings.general.defaultFeed, + ); + const iss = useAppSelector(jwtIssSelector); + const firstEnter = useRef(true); + + useIonViewDidEnter(() => { + if (!firstEnter.current) return; + firstEnter.current = false; + + if (!isInstalled()) return; + + const baseRoute = getBaseRoute(!!iss, defaultFeed); + + // user set default page = communities list. We're already there. + if (!baseRoute) { + setBootstrapped(true); + return; + } + + requestAnimationFrame(() => { + router.push( + buildGeneralBrowseLink(baseRoute), + "forward", + "push", + undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (baseEl: any, opts: TransitionOptions) => { + // Do not animate into view + if (opts.direction === "forward") return createAnimation(); + + return opts.mode === "ios" + ? iosTransitionAnimation(baseEl, opts) + : mdTransitionAnimation(baseEl, opts); + }, + ); + + requestAnimationFrame(() => setBootstrapped(true)); + }); + }); + + if (!isInstalled() || bootstrapped) return null; + return ; +} + +export function getBaseRoute( + loggedIn: boolean, + defaultFeed: DefaultFeedType | undefined, +): string { + if (loggedIn) + return getPathForFeed(defaultFeed || { type: ODefaultFeedType.Home }); + + return "/all"; +} diff --git a/src/pages/posts/CommunitiesPage.tsx b/src/pages/posts/CommunitiesPage.tsx index 38c7df89f9..67e46c164c 100644 --- a/src/pages/posts/CommunitiesPage.tsx +++ b/src/pages/posts/CommunitiesPage.tsx @@ -11,6 +11,7 @@ import { import CommunitiesMoreActions from "../../features/community/list/InstanceMoreActions"; import FeedContent from "../shared/FeedContent"; import { useParams } from "react-router"; +import CommunitiesListRedirectBootstrapper from "../../features/community/list/CommunitiesListRedirectBootstrapper"; export default function CommunitiesPage() { const { actor } = useParams<{ actor: string }>(); @@ -19,18 +20,21 @@ export default function CommunitiesPage() { useSetActivePage(pageRef); return ( - - - - Communities - - - - - - - - - + <> + + + + + Communities + + + + + + + + + + > ); } diff --git a/src/services/app.ts b/src/services/app.ts index 0c0930ebe8..6d5499b22f 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -38,7 +38,7 @@ interface ConfigProviderProps { } export default function ConfigProvider({ children }: ConfigProviderProps) { - const [configLoaded, setConfigLoaded] = useState(false); + const [configLoaded, setConfigLoaded] = useState(isNative()); // native does not load config useEffect(() => { // Config is not necessary for app to run From 3df39673b7f2f42eb8e06f316b1af584844bc11d Mon Sep 17 00:00:00 2001 From: Alexander Harding <2166114+aeharding@users.noreply.github.com> Date: Sat, 2 Dec 2023 15:08:56 -0600 Subject: [PATCH 7/8] Fix text sometimes not visible on share as image (#1005) --- src/index.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.css b/src/index.css index 4b2c729c36..2b9da7bf01 100644 --- a/src/index.css +++ b/src/index.css @@ -238,6 +238,7 @@ ion-toast.center::part(container) { font-size: 16px; --width: 330px; + color: var(--ion-text-color); position: absolute; width: var(--width); From 31f4d546dd7ae7d2e161344fefd4aba4eee10c24 Mon Sep 17 00:00:00 2001 From: Alexander Harding <2166114+aeharding@users.noreply.github.com> Date: Sat, 2 Dec 2023 15:12:39 -0600 Subject: [PATCH 8/8] Release 1.28.0 --- android/app/build.gradle | 4 ++-- ios/App/App/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index b1f60de52a..9715bd9650 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "app.vger.voyager" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 183 - versionName "1.27.0" + versionCode 184 + versionName "1.28.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index 17614c6d11..92afb3d901 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.27.0 + 1.28.0 CFBundleVersion - 183 + 184 LSRequiresIPhoneOS UILaunchStoryboardName diff --git a/package.json b/package.json index ccde42aac7..c32f2966f2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "voyager", "description": "A progressive webapp Lemmy client", "private": true, - "version": "1.27.0", + "version": "1.28.0", "type": "module", "packageManager": "pnpm@8.9.2+sha256.8d62573d93061f2722b7b48c9739e96cd4603c3ab153bc81c619dcb9861a214e", "scripts": {