diff --git a/apps/mobile/native/ios/Helper/Helper+Image.swift b/apps/mobile/native/ios/Helper/Helper+Image.swift
index 83c26f8c6..0ce8d9ad7 100644
--- a/apps/mobile/native/ios/Helper/Helper+Image.swift
+++ b/apps/mobile/native/ios/Helper/Helper+Image.swift
@@ -48,8 +48,6 @@ class PreviewControllerController: QLPreviewController, QLPreviewControllerDataS
}
override func viewDidLoad() {
- debugPrint(previewContentDirectory, "previewContentDirectory")
-
super.viewDidLoad()
delegate = self
dataSource = self
@@ -59,6 +57,36 @@ class PreviewControllerController: QLPreviewController, QLPreviewControllerDataS
if initialIndex < imageDataArray.count {
self.currentPreviewItemIndex = initialIndex
}
+
+ // Add save button to navigation bar
+ let saveButton = UIBarButtonItem(
+ image: UIImage(systemName: "square.and.arrow.down"),
+ style: .plain,
+ target: self,
+ action: #selector(saveCurrentImage)
+ )
+ navigationItem.leftBarButtonItem = saveButton
+ }
+
+ @objc private func saveCurrentImage() {
+ let currentIndex = currentPreviewItemIndex
+ guard currentIndex < imageDataArray.count else { return }
+
+ let imageData = imageDataArray[currentIndex]
+ guard let image = UIImage(data: imageData) else { return }
+
+ UIImageWriteToSavedPhotosAlbum(
+ image, self, #selector(image(_:didFinishSavingWithError:contextInfo:)), nil)
+ }
+
+ @objc private func image(
+ _ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer
+ ) {
+ if let error = error {
+ print("Error saving image: \(error.localizedDescription)")
+ } else {
+ print("Image saved successfully")
+ }
}
private func cleanupTempFiles() {
@@ -77,7 +105,7 @@ class PreviewControllerController: QLPreviewController, QLPreviewControllerDataS
class ImagePreview: NSObject {
public static func quickLookImage(_ images: [Data], index: Int = 0) {
- guard let rootViewController = UIApplication.shared.keyWindow?.rootViewController else {
+ guard let rootViewController = Utils.getRootVC() else {
return
}
diff --git a/apps/mobile/native/ios/SharedWebView/WebViewManager.swift b/apps/mobile/native/ios/SharedWebView/WebViewManager.swift
index cac56ac98..92f597939 100644
--- a/apps/mobile/native/ios/SharedWebView/WebViewManager.swift
+++ b/apps/mobile/native/ios/SharedWebView/WebViewManager.swift
@@ -6,7 +6,7 @@
//
import Combine
import ExpoModulesCore
-import WebKit
+@preconcurrency import WebKit
private var pendingJavaScripts: [String] = []
@@ -123,7 +123,7 @@ enum WebViewManager {
}()
private static func setupWebView(_ webView: WKWebView) {
- let viewController = UIApplication.shared.keyWindow?.rootViewController
+ guard let viewController = Utils.getRootVC() else { return }
let delegate = WebViewDelegate(state: state, viewController: viewController)
webView.navigationDelegate = delegate
webView.uiDelegate = delegate
diff --git a/apps/mobile/native/ios/Utils/Utils.swift b/apps/mobile/native/ios/Utils/Utils.swift
index fe3529cbd..1bb9efda9 100644
--- a/apps/mobile/native/ios/Utils/Utils.swift
+++ b/apps/mobile/native/ios/Utils/Utils.swift
@@ -14,5 +14,13 @@ enum Utils {
static let bundle = Bundle(for: Noop.self)
static let accentColor =
UIColor(named: "Accent", in: bundle, compatibleWith: nil) ?? UIColor.systemBlue
-
+
+ static func getRootVC() -> UIViewController? {
+ if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let window = scene.windows.first,
+ let rootVC = window.rootViewController {
+ return rootVC
+ }
+ return nil
+ }
}
diff --git a/apps/mobile/src/screens/(stack)/entries/[entryId]/index.tsx b/apps/mobile/src/screens/(stack)/entries/[entryId]/index.tsx
index dd817366d..750c12a5e 100644
--- a/apps/mobile/src/screens/(stack)/entries/[entryId]/index.tsx
+++ b/apps/mobile/src/screens/(stack)/entries/[entryId]/index.tsx
@@ -1,23 +1,15 @@
import { PortalProvider } from "@gorhom/portal"
import { BottomTabBarHeightContext } from "@react-navigation/bottom-tabs"
-import { HeaderTitle } from "@react-navigation/elements"
+import { useHeaderHeight } from "@react-navigation/elements"
import type { AVPlaybackStatus } from "expo-av"
import { Video } from "expo-av"
import { Image } from "expo-image"
import { useLocalSearchParams } from "expo-router"
import type { FC } from "react"
import { Fragment, useContext, useEffect, useState } from "react"
-import {
- Animated,
- Pressable,
- Text,
- TouchableOpacity,
- useAnimatedValue,
- useWindowDimensions,
- View,
-} from "react-native"
+import { Pressable, Text, TouchableOpacity, useWindowDimensions, View } from "react-native"
import PagerView from "react-native-pager-view"
-import ReAnimated, { FadeIn, FadeOut } from "react-native-reanimated"
+import ReAnimated, { FadeIn, FadeOut, useSharedValue, withTiming } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useColor } from "react-native-uikit-colors"
@@ -150,40 +142,23 @@ const EntryTitle = ({ title }: { title: string }) => {
const { scrollY } = useContext(NavigationContext)!
const [titleHeight, setTitleHeight] = useState(0)
- const [navTitle, setNavTitle] = useState("")
- const opacityAnimatedValue = useAnimatedValue(0)
+ const opacityAnimatedValue = useSharedValue(0)
- const insets = useSafeAreaInsets()
+ const headerHeight = useHeaderHeight()
useEffect(() => {
- let animatedId = 0
const id = scrollY.addListener((value) => {
- if (value.value > titleHeight + insets.top) {
- setNavTitle(title)
- Animated.timing(opacityAnimatedValue, {
- toValue: 1,
- duration: 100,
- useNativeDriver: true,
- }).start()
- animatedId++
+ if (value.value > titleHeight + headerHeight) {
+ opacityAnimatedValue.value = withTiming(1, { duration: 100 })
} else {
- const currentId = ++animatedId
- Animated.timing(opacityAnimatedValue, {
- toValue: 0,
- duration: 100,
- useNativeDriver: true,
- }).start(({ finished }) => {
- if (finished && currentId === animatedId) {
- setNavTitle("")
- }
- })
+ opacityAnimatedValue.value = withTiming(0, { duration: 100 })
}
})
return () => {
scrollY.removeListener(id)
}
- }, [scrollY, title, titleHeight, insets.top, opacityAnimatedValue])
+ }, [scrollY, title, titleHeight, headerHeight, opacityAnimatedValue])
return (
<>
@@ -191,7 +166,13 @@ const EntryTitle = ({ title }: { title: string }) => {
headerShown
headerRight={HeaderRightActions}
headerTitle={() => (
- {navTitle}
+
+ {title}
+
)}
/>
(null)
diff --git a/apps/mobile/web-app/html-renderer/hooks/useCalculateNaturalSize.tsx b/apps/mobile/web-app/html-renderer/hooks/useCalculateNaturalSize.tsx
new file mode 100644
index 000000000..e8a9c42d8
--- /dev/null
+++ b/apps/mobile/web-app/html-renderer/hooks/useCalculateNaturalSize.tsx
@@ -0,0 +1,73 @@
+import { useCallback, useReducer } from "react"
+
+export const calculateDimensions = ({
+ width,
+ height,
+ max,
+}: {
+ width: number
+ height: number
+ max: { width: number; height: number }
+}) => {
+ if (width === 0 || height === 0) return { width: 0, height: 0 }
+
+ const { width: maxW, height: maxH } = max
+
+ const wRatio = maxW / width || 1
+ const hRatio = maxH / height || 1
+
+ const ratio = Math.min(wRatio, hRatio, 1)
+
+ return {
+ width: width * ratio,
+ height: height * ratio,
+ }
+}
+
+const initialState = { height: 0, width: 0 }
+type Action = { type: "set"; height: number; width: number } | { type: "reset" }
+export const useCalculateNaturalSize = () => {
+ const [state, dispatch] = useReducer((state: typeof initialState, payload: Action) => {
+ switch (payload.type) {
+ case "set": {
+ return {
+ height: payload.height,
+ width: payload.width,
+ }
+ }
+ case "reset": {
+ return initialState
+ }
+ default: {
+ return state
+ }
+ }
+ }, initialState)
+
+ const calculateOnImageEl = useCallback((imageEl: HTMLImageElement, parentElWidth?: number) => {
+ if (!parentElWidth || !imageEl) {
+ return
+ }
+
+ const w = imageEl.naturalWidth,
+ h = imageEl.naturalHeight
+ if (w && h) {
+ const calculated = calculateDimensions({
+ width: w,
+ height: h,
+ max: {
+ height: Infinity,
+ width: +parentElWidth,
+ },
+ })
+
+ dispatch({
+ type: "set",
+ height: calculated.height,
+ width: calculated.width,
+ })
+ }
+ }, [])
+
+ return [state, calculateOnImageEl] as const
+}
diff --git a/apps/mobile/web-app/html-renderer/package.json b/apps/mobile/web-app/html-renderer/package.json
index 15af8a45d..c6666045f 100644
--- a/apps/mobile/web-app/html-renderer/package.json
+++ b/apps/mobile/web-app/html-renderer/package.json
@@ -12,6 +12,7 @@
"@follow/components": "workspace:*",
"@follow/types": "workspace:*",
"clsx": "2.1.1",
- "jotai": "2.11.3"
+ "jotai": "2.11.3",
+ "react-blurhash": "^0.3.0"
}
}
diff --git a/apps/mobile/web-app/html-renderer/src/App.tsx b/apps/mobile/web-app/html-renderer/src/App.tsx
index cdcea7d9e..87271e976 100644
--- a/apps/mobile/web-app/html-renderer/src/App.tsx
+++ b/apps/mobile/web-app/html-renderer/src/App.tsx
@@ -1,24 +1,10 @@
-import { atom, createStore, useAtomValue } from "jotai"
+import { createStore, Provider, useAtomValue } from "jotai"
+import { entryAtom } from "../atoms"
+import type { EntryModel } from "../types"
import { HTML } from "./HTML"
-interface MediaModel {
- url: string
- type: "photo" | "video"
- preview_image_url?: string
- width?: number
- height?: number
- blurhash?: string
-}
-
-interface EntryModel {
- content: string
- title: string
- media: MediaModel[]
-}
-
const store = createStore()
-const entryAtom = atom(null)
Object.assign(window, {
setEntry(entry: EntryModel) {
@@ -34,5 +20,9 @@ Object.assign(window, {
export const App = () => {
const entry = useAtomValue(entryAtom, { store })
- return
+ return (
+
+
+
+ )
}
diff --git a/apps/mobile/web-app/html-renderer/src/components/image.tsx b/apps/mobile/web-app/html-renderer/src/components/image.tsx
index e63976ce0..a1d43ed64 100644
--- a/apps/mobile/web-app/html-renderer/src/components/image.tsx
+++ b/apps/mobile/web-app/html-renderer/src/components/image.tsx
@@ -1,15 +1,45 @@
-import { useRef } from "react"
+import clsx from "clsx"
+import { useAtomValue } from "jotai"
+import { useContext, useMemo, useRef, useState } from "react"
+import { Blurhash } from "react-blurhash"
import type { HTMLProps } from "~/HTML"
+import { entryAtom } from "../../atoms"
+import { calculateDimensions } from "../../hooks/useCalculateNaturalSize"
+import { MarkdownRenderContainerRefContext } from "./__internal/ctx"
+
export const MarkdownImage = (props: HTMLProps<"img">) => {
const { src, ...rest } = props
const imageRef = useRef(null)
+ const entry = useAtomValue(entryAtom)
+
+ const [isLoading, setIsLoading] = useState(true)
+ const ref = useContext(MarkdownRenderContainerRefContext)
+ const image = entry?.media.find((media) => media.url === src)
+ const { height: scaleHeight, width: scaleWidth } = useMemo(
+ () =>
+ calculateDimensions({
+ width: image?.width ?? 0,
+ height: image?.height ?? 0,
+ max: {
+ width: ref?.clientWidth ?? 0,
+ height: window.innerHeight,
+ },
+ }),
+ [image?.width, image?.height, ref?.clientWidth],
+ )
+
return (
)
}
diff --git a/apps/mobile/web-app/html-renderer/tsconfig.json b/apps/mobile/web-app/html-renderer/tsconfig.json
index c916a5baf..510b634da 100644
--- a/apps/mobile/web-app/html-renderer/tsconfig.json
+++ b/apps/mobile/web-app/html-renderer/tsconfig.json
@@ -9,13 +9,12 @@
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
- "baseUrl": ".",
"noImplicitReturns": false,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client", "@follow/types/global", "@follow/types/react"],
"paths": {
- "~/*": ["src/*"]
+ "~/*": ["./src/*"]
}
}
}
diff --git a/apps/mobile/web-app/html-renderer/types/index.ts b/apps/mobile/web-app/html-renderer/types/index.ts
new file mode 100644
index 000000000..1e80cff3e
--- /dev/null
+++ b/apps/mobile/web-app/html-renderer/types/index.ts
@@ -0,0 +1,14 @@
+export interface MediaModel {
+ url: string
+ type: "photo" | "video"
+ preview_image_url?: string
+ width?: number
+ height?: number
+ blurhash?: string
+}
+
+export interface EntryModel {
+ content: string
+ title: string
+ media: MediaModel[]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2114eae40..1dfa2fe89 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,59 +4,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
-overrides:
- '@types/react': npm:@types/react@^18.3.12
- '@types/react-dom': npm:@types/react-dom@^18.3.1
- app-builder-lib: npm:app-builder-lib@^25.1.8
- electron: 33.2.0
- esbuild: npm:esbuild@^0.24.2
- expo-modules-core: npm:expo-modules-core@2.2.0
- is-core-module: npm:@nolyfill/is-core-module@^1
- isarray: npm:@nolyfill/isarray@^1
- lightningcss: npm:lightningcss@^1.29.1
- react-native: npm:react-native@0.77.0
- typescript: 5.7.3
- unist-util-visit-parents: 5.1.3
- vfile: 5.3.7
-
-patchedDependencies:
- '@microflash/remark-callout-directives':
- hash: bl6uhe4vs4xm3d2jmecdpzbh2m
- path: patches/@microflash__remark-callout-directives.patch
- '@mozilla/readability':
- hash: 43niildbdafdxi7qfcwhpkkxwa
- path: patches/@mozilla__readability.patch
- '@pengx17/electron-forge-maker-appimage':
- hash: vov3v67fgv3lrfz3n24bnubw4m
- path: patches/@pengx17__electron-forge-maker-appimage.patch
- '@tanstack/react-virtual':
- hash: 44veyovhgqddxy4cx3biv6jmoa
- path: patches/@tanstack__react-virtual.patch
- daisyui:
- hash: igsntdatmoaxzwxof4bkkh35fy
- path: patches/daisyui.patch
- devlop:
- hash: xzgzapu45daboiid4fiusduvwa
- path: patches/devlop.patch
- electron-context-menu:
- hash: c53at4t5fflixuwjz35hmcqdu4
- path: patches/electron-context-menu.patch
- hono:
- hash: qptujxncoai6tukc4qaqsrqk24
- path: patches/hono.patch
- immer@10.1.1:
- hash: og7mbnoo5vh43tjlw5rbmrdbvu
- path: patches/immer@10.1.1.patch
- jsonpointer:
- hash: prxuhlhyjugus5tiew4vc3pahu
- path: patches/jsonpointer.patch
- re-resizable:
- hash: yitcmpfwcomg2fky72uc3lhk2i
- path: patches/re-resizable@6.9.17.patch
- workbox-precaching:
- hash: frtipzgil4wle57aliuhcr746e
- path: patches/workbox-precaching.patch
-
importers:
.:
@@ -698,6 +645,24 @@ importers:
specifier: ^4.0.3
version: 4.0.3(3g6n6qn7ir7dwkefeyw6r7oy24)
+ apps/mobile/web-app/html-renderer:
+ dependencies:
+ '@follow/components':
+ specifier: workspace:*
+ version: link:../../../../packages/components
+ '@follow/types':
+ specifier: workspace:*
+ version: link:../../../../packages/types
+ clsx:
+ specifier: 2.1.1
+ version: 2.1.1
+ jotai:
+ specifier: 2.11.3
+ version: 2.11.3(@types/react@18.3.18)(react@18.3.1)
+ react-blurhash:
+ specifier: ^0.3.0
+ version: 0.3.0(blurhash@2.0.5)(react@18.3.1)
+
apps/renderer:
dependencies:
'@dnd-kit/core':
@@ -10096,7 +10061,7 @@ packages:
resolution: {integrity: sha512-B/PsewAQ0UOS5e2+TTWegUPQ3SCLPCjPY24LYUjfn2EorGlluTA2dFjVLgF1+xHLjK9Jit3y5mKHyMG3Xq/GZg==}
engines: {node: '>=12.20.0'}
peerDependencies:
- '@types/react': npm:@types/react@^18.3.12
+ '@types/react': '>=17.0.0'
react: '>=17.0.0'
peerDependenciesMeta:
'@types/react':