feat: scrollview scroll to end detection

- Refactored HelperModule to include new async functions for scrolling and image handling.
- Removed ProfileViewModule and associated UI components to streamline the codebase.
- Updated expo-module.config.json to reflect the removal of ProfileViewModule.
- Improved SafeNavigationScrollView and TimelineSelectorList to utilize new scroll handling features.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-04-07 19:31:56 +08:00
parent db5c4c04dd
commit 4fc8ec3f42
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
12 changed files with 217 additions and 783 deletions

View File

@ -11,7 +11,6 @@
"TabBarModule",
"TabScreenModule",
"TabBarPortalModule",
"ProfileViewModule",
"EnhancePagerViewModule",
"EnhancePageViewModule"
]

View File

@ -8,180 +8,195 @@ import ExpoModulesCore
import UIKit
public class HelperModule: Module {
public func definition() -> ExpoModulesCore.ModuleDefinition {
Name("Helper")
public func definition() -> ExpoModulesCore.ModuleDefinition {
Name("Helper")
AsyncFunction("openLink") { (urlString: String, promise: Promise) in
guard let url = URL(string: urlString) else {
return
}
DispatchQueue.main.async {
guard let rootVC = Utils.getRootVC() else { return }
AsyncFunction("openLink") { (urlString: String, promise: Promise) in
guard let url = URL(string: urlString) else {
return
}
DispatchQueue.main.async {
guard let rootVC = Utils.getRootVC() else { return }
let onDismiss = {
promise.resolve(["type": "dismiss"])
}
let onDismiss = {
promise.resolve(["type": "dismiss"])
WebViewManager.presentModalWebView(url: url, from: rootVC, onDismiss: onDismiss)
}
}
WebViewManager.presentModalWebView(url: url, from: rootVC, onDismiss: onDismiss)
}
}
Function("scrollToTop") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
Function("scrollToTop") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let scrollView = self?.findUIScrollView(view: sourceView)
guard let scrollView = scrollView else {
return
}
scrollView.scrollToTopIfPossible(animated: true)
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let scrollView = self?.findUIScrollView(view: sourceView)
guard let scrollView = scrollView else {
return
}
scrollView.scrollToTopIfPossible(animated: true)
}
}
}
}
}
Function("saveImageByHandle") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
AsyncFunction("isScrollToEnd") { (reactTag: Int, promise: Promise) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
UIImageWriteToSavedPhotosAlbum(image, self, #selector(HelperModule.image), nil)
SPIndicator.present(title: "Saved to photos", preset: .done, haptic: .success)
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let scrollView = self?.findUIScrollView(view: sourceView)
guard let scrollView = scrollView else {
return
}
let contentHeight = scrollView.contentSize.height
let scrollViewHeight = scrollView.bounds.size.height
let contentOffsetY = scrollView.contentOffset.y
let bottomOffset = contentHeight - scrollViewHeight
promise.resolve(contentOffsetY >= bottomOffset - 1.0)
}
}
}
}
}
Function("shareImageByHandle") { (reactTag: Int, url: String?) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
Function("saveImageByHandle") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
let activityViewController = UIActivityViewController(
activityItems: [
image.asActivityItemSource(
url: try? URL(string: url ?? "")
)
], applicationActivities: nil)
activityViewController.popoverPresentationController?.sourceView = sourceView
activityViewController.popoverPresentationController?.sourceRect = sourceView.bounds
activityViewController.popoverPresentationController?.permittedArrowDirections = .any
activityViewController.popoverPresentationController?.permittedArrowDirections = .any
Utils.getRootVC()?.present(activityViewController, animated: true)
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
UIImageWriteToSavedPhotosAlbum(image, self, #selector(HelperModule.image), nil)
SPIndicator.present(title: "Saved to photos", preset: .done, haptic: .success)
}
}
}
}
}
AsyncFunction("getBase64FromImageViewByHandle") { (reactTag: Int, promise: Promise) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
Function("shareImageByHandle") { (reactTag: Int, url: String?) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
let activityViewController = UIActivityViewController(
activityItems: [
image.asActivityItemSource(
url: try? URL(string: url ?? "")
),
], applicationActivities: nil)
activityViewController.popoverPresentationController?.sourceView = sourceView
activityViewController.popoverPresentationController?.sourceRect = sourceView.bounds
activityViewController.popoverPresentationController?.permittedArrowDirections = .any
activityViewController.popoverPresentationController?.permittedArrowDirections = .any
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
promise.reject(
NSError(
domain: "HelperModule", code: 0,
userInfo: [NSLocalizedDescriptionKey: "Image view not found"]))
return
}
let base64 = self?.getBase64FromImageView(imageView: imageView)
promise.resolve(["base64": base64])
Utils.getRootVC()?.present(activityViewController, animated: true)
}
}
}
}
}
Function("copyImageByHandle") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
AsyncFunction("getBase64FromImageViewByHandle") { (reactTag: Int, promise: Promise) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
guard let imageData = image.pngData() else { return }
UIPasteboard.general.setData(imageData, forPasteboardType: "public.png")
SPIndicator.present(title: "Image copied to clipboard", preset: .done, haptic: .success)
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
promise.reject(
NSError(
domain: "HelperModule", code: 0,
userInfo: [NSLocalizedDescriptionKey: "Image view not found"]))
return
}
let base64 = self?.getBase64FromImageView(imageView: imageView)
promise.resolve(["base64": base64])
}
}
}
}
}
}
func getBase64FromImageView(imageView: UIImageView) -> String? {
guard let image = imageView.image else { return nil }
guard let imageData = image.pngData() else { return nil }
Function("copyImageByHandle") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
let base64String = imageData.base64EncodedString(options: .lineLength64Characters)
return base64String
}
@objc func image(
_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer?
) {
if let error = error {
SPIndicator.present(title: "Save image failed", preset: .error, haptic: .error)
} else {
SPIndicator.present(title: "Save image success", preset: .done, haptic: .success)
}
}
private func findUIScrollView(view: UIView?) -> UIScrollView? {
return findUIViewOfType(view: view)
}
private func findUIImageView(view: UIView?) -> UIImageView? {
return findUIViewOfType(view: view)
}
private func findUIViewOfType<T: UIView>(view: UIView?) -> T? {
guard let view = view else {
return nil
}
if let view = view as? T {
return view
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
guard let imageData = image.pngData() else { return }
UIPasteboard.general.setData(imageData, forPasteboardType: "public.png")
SPIndicator.present(title: "Image copied to clipboard", preset: .done, haptic: .success)
}
}
}
}
let subviews = view.subviews
for subview in subviews {
if let targetView = findUIViewOfType(view: subview) as T? {
return targetView
}
func getBase64FromImageView(imageView: UIImageView) -> String? {
guard let image = imageView.image else { return nil }
guard let imageData = image.pngData() else { return nil }
let base64String = imageData.base64EncodedString(options: .lineLength64Characters)
return base64String
}
@objc func image(
_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer?
) {
if let error = error {
SPIndicator.present(title: "Save image failed", preset: .error, haptic: .error)
} else {
SPIndicator.present(title: "Save image success", preset: .done, haptic: .success)
}
}
private func findUIScrollView(view: UIView?) -> UIScrollView? {
return findUIViewOfType(view: view)
}
private func findUIImageView(view: UIView?) -> UIImageView? {
return findUIViewOfType(view: view)
}
private func findUIViewOfType<T: UIView>(view: UIView?) -> T? {
guard let view = view else {
return nil
}
if let view = view as? T {
return view
}
let subviews = view.subviews
for subview in subviews {
if let targetView = findUIViewOfType(view: subview) as T? {
return targetView
}
}
return nil
}
return nil
}
}
extension UIScrollView {
func scrollToTopIfPossible(animated: Bool) {
let encodedSelector = "X3Njcm9sbFRvVG9wSWZQb3NzaWJsZTo=" // "_scrollToTopIfPossible:"
func scrollToTopIfPossible(animated: Bool) {
let encodedSelector = "X3Njcm9sbFRvVG9wSWZQb3NzaWJsZTo=" // "_scrollToTopIfPossible:"
if let decodedData = Data(base64Encoded: encodedSelector),
let decodedString = String(data: decodedData, encoding: .utf8)
{
let selector = NSSelectorFromString(decodedString)
if self.responds(to: selector) {
self.perform(selector, with: animated)
} else {
print("UIScrollView does not respond to decoded method")
self.setContentOffset(.zero, animated: animated)
}
} else {
self.setContentOffset(.zero, animated: animated)
if let decodedData = Data(base64Encoded: encodedSelector),
let decodedString = String(data: decodedData, encoding: .utf8) {
let selector = NSSelectorFromString(decodedString)
if responds(to: selector) {
perform(selector, with: animated)
} else {
print("UIScrollView does not respond to decoded method")
setContentOffset(.zero, animated: animated)
}
} else {
setContentOffset(.zero, animated: animated)
}
}
}
}

View File

@ -1,41 +0,0 @@
//
// ProfileViewModule.swift
// Pods
//
// Created by Innei on 2025/3/24.
//
import ExpoModulesCore
import SwiftUI
public class ProfileViewModule: Module {
public func definition() -> ModuleDefinition {
Name("ProfileView")
Events("onPress")
View(ExpoProfileView.self)
}
}
public class ProfileViewProps: ExpoSwiftUI.ViewProps {
@Field var payload: String
let onPress = EventDispatcher()
}
struct ProfilePayload: Codable {
var profile: ProfileData
var user: UserData
public static func parse(jsonString: String) -> ProfilePayload? {
let data = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
return try? decoder.decode(ProfilePayload.self, from: data)
}
}
struct ExpoProfileView: ExpoSwiftUI.View {
@EnvironmentObject var props: ProfileViewProps
var body: some View {
if let payload = ProfilePayload.parse(jsonString: props.payload) {
ProfileView(profile: .constant(payload.profile), user: .constant(payload.user), onPress: props.onPress)
}
}
}

View File

@ -1,80 +0,0 @@
//
// FallbackIconView.swift
// FollowNative
//
// Created by Innei on 2025/3/24.
//
import SwiftUI
struct FallbackIcon: View {
var title: String
var size: CGFloat
var gray: Bool = false
var body: some View {
ZStack {
// Generate gradient colors based on the title
LinearGradient(
gradient: getGradientForTitle(title: title, gray: gray),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size * 0.2))
// Display first character or first two characters based on if it's CJK
Text(getDisplayText(from: title))
.font(.system(size: size * 0.5))
.foregroundColor(.white)
}
}
private func getDisplayText(from title: String) -> String {
guard !title.isEmpty else { return "" }
// Check if the first character is CJK (Chinese, Japanese, Korean)
let firstChar = title.first!
let isCJK = isCJKCharacter(firstChar)
if isCJK {
return String(firstChar)
} else {
return title.prefix(2).uppercased()
}
}
private func isCJKCharacter(_ character: Character) -> Bool {
// Unicode ranges for CJK characters
let cjkRanges: [ClosedRange<UInt32>] = [
0x4E00...0x9FFF, // CJK Unified Ideographs
0x3040...0x309F, // Hiragana
0x30A0...0x30FF, // Katakana
0xAC00...0xD7AF, // Hangul Syllables
]
let unicodeScalar = character.unicodeScalars.first!.value
return cjkRanges.contains { $0.contains(unicodeScalar) }
}
private func getGradientForTitle(title: String, gray: Bool) -> Gradient {
if gray {
return Gradient(colors: [Color.gray, Color.gray.opacity(0.7)])
}
// Simple hash function to generate consistent colors based on title
var hash = 0
for char in title {
hash = ((hash << 5) &- hash) &+ Int(char.asciiValue ?? 0)
}
// Generate hue values between 0 and 1
let hue = abs(Double(hash % 360) / 360.0)
return Gradient(colors: [
Color(hue: hue, saturation: 0.7, brightness: 0.8),
Color(hue: hue, saturation: 0.5, brightness: 0.6),
])
}
}

View File

@ -1,167 +0,0 @@
//
// FeedIconView.swift
//
// Created by Innei on 2025/3/24.
//
import SwiftUI
struct FeedIconView: View {
var feed: FeedIconFeedData?
var fallbackUrl: String?
var size: CGFloat = 20
var fallback: Bool = true
var siteUrl: String?
var body: some View {
if let iconSource = getFeedIconSource() {
AsyncImage(url: URL(string: iconSource)) { phase in
switch phase {
case .empty:
RoundedRectangle(cornerRadius: CGFloat(4), style: .continuous)
.fill(Color.gray.opacity(0.5))
.frame(width: size, height: size)
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 4))
case .failure:
let fallbackTitle = feed?.title ?? extractDomainFromUrl(siteUrl ?? fallbackUrl ?? "")
FallbackIcon(title: fallbackTitle, size: size)
@unknown default:
RoundedRectangle(cornerRadius: CGFloat(4), style: .continuous)
.fill(Color.gray.opacity(0.5))
.frame(width: size, height: size)
}
}
.frame(width: size, height: size)
} else {
let fallbackTitle = feed?.title ?? extractDomainFromUrl(siteUrl ?? fallbackUrl ?? "")
FallbackIcon(title: fallbackTitle, size: size)
}
}
private func getFeedIconSource() -> String? {
switch true {
case feed == nil && siteUrl != nil:
return getUrlIcon(url: siteUrl!, fallback: fallback).src
case feed != nil && feed?.image != nil && !(feed?.image?.isEmpty ?? true):
return feed?.image
case feed != nil && (feed?.image == nil || feed?.image?.isEmpty ?? true)
&& feed?.siteUrl != nil:
return getUrlIcon(url: feed!.siteUrl!, fallback: fallback).src
default:
return nil
}
}
private func getUrlIcon(url: String, fallback: Bool) -> (src: String, fallbackUrl: String) {
var src: String
var fallbackUrl = ""
if let urlObj = URL(string: url), let host = urlObj.host {
let pureDomain = extractDomainFromUrl(host)
fallbackUrl =
"https://avatar.vercel.sh/\(pureDomain).svg?text=\(pureDomain.prefix(2).uppercased())"
src = "https://unavatar.webp.se/\(host)?fallback=\(fallback)"
} else {
let pureDomain = extractDomainFromUrl(url)
src = "https://avatar.vercel.sh/\(pureDomain).svg?text=\(pureDomain.prefix(2).uppercased())"
}
return (src, fallbackUrl)
}
private func extractDomainFromUrl(_ url: String) -> String {
// Simple domain extraction - in a real app you'd want to use a more robust solution
let components = url.components(separatedBy: ".")
if components.count >= 2 {
return components[components.count - 2]
}
return url
}
}
// Feed data model
struct FeedIconFeedData: Identifiable {
var id: String
var title: String
var url: String
var image: String?
var siteUrl: String?
static func transform(_ feed: ProfileFeed) -> FeedIconFeedData {
FeedIconFeedData(
id: feed.id,
title: feed.title,
url: feed.url,
image: feed.image,
siteUrl: feed.siteUrl
)
}
#if DEBUG
static var mockData: FeedIconFeedData {
FeedIconFeedData(
id: "1",
title: "Example Feed",
url: "https://example.com/feed",
image: nil,
siteUrl: "https://example.com"
)
}
#endif
}
#Preview {
VStack(spacing: 20) {
// Feed with image
FeedIconView(
feed: FeedIconFeedData(
id: "1",
title: "Example Feed",
url: "https://example.com/feed",
image: "https://picsum.photos/200",
siteUrl: "https://example.com"
),
size: 40
)
// Feed without image, using site URL
FeedIconView(
feed: FeedIconFeedData(
id: "2",
title: "No Image Feed",
url: "https://noimage.com/feed",
image: nil,
siteUrl: "https://noimage.com"
),
size: 40
)
// Just a site URL
FeedIconView(
size: 40, siteUrl: "https://github.com"
)
// Fallback
FeedIconView(
feed: FeedIconFeedData(
id: "3",
title: "Fallback Example",
url: "https://invalid-url",
image: nil,
siteUrl: nil
),
size: 40
)
}
.padding()
}

View File

@ -1,180 +0,0 @@
//
// ProfileView.swift
//
// Created by Innei on 2025/3/24.
//
import ExpoModulesCore
import SwiftUI
struct ProfileView: View {
@Binding var profile: ProfileData
@Binding var user: UserData
let onPress: EventDispatcher
var hasAnyFeeds: Bool {
!profile.feeds.isEmpty || !profile.groupedFeeds.isEmpty
}
var body: some View {
List {
VStack {
VStack {
ProfileAvatar(image: user.image, name: user.name)
Text(user.name)
.font(.title)
.fontWeight(.bold)
.lineLimit(1)
if !user.handle.isEmpty {
Text("@\(user.handle)")
.foregroundColor(.gray)
}
}
.frame(maxWidth: .infinity)
.padding(.top, 30)
.padding(.bottom, 20)
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
}.listRowInsets(.none)
if !profile.lists.isEmpty {
Section(header: SectionHeaderText(text: "Lists")) {
ForEach($profile.lists, id: \.id) { $list in
Button {
onPress(["type": "list", "id": list.id])
} label: {
HStack {
ProfileListImage(
image: list.image, title: list.title,
customTitle: list.customTitle)
ProfileItemTitle(title: list.title, customTitle: list.customTitle)
}
}.foregroundStyle(.foreground)
}
}
}
if hasAnyFeeds {
Section(header: SectionHeaderText(text: "Feeds")) {
}
}
ForEach(profile.groupedFeeds.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
Section(
header:
Text(key).textCase(.none)
) {
ForEach(value, id: \.id) { feed in
Button {
onPress(["type": "feed", "id": feed.id])
} label: {
HStack {
FeedIconView(
feed: FeedIconFeedData.transform(feed), size: 24,
fallback: true,
siteUrl: feed.siteUrl
)
ProfileItemTitle(title: feed.title, customTitle: feed.customTitle)
}
}.foregroundStyle(.foreground)
}
}
}
if !profile.feeds.isEmpty {
Section(header: Text("Uncategorized Feeds").textCase(.none)) {
ForEach($profile.feeds, id: \.id) { $feed in
Button {
onPress(["type": "feed", "id": feed.id])
} label: {
HStack {
FeedIconView(
feed: FeedIconFeedData.transform(feed), size: 24,
fallback: true,
siteUrl: feed.siteUrl
)
ProfileItemTitle(title: feed.title, customTitle: feed.customTitle)
}
}.foregroundStyle(.foreground)
}
}
}
}.listStyle(.insetGrouped)
}
}
private struct ProfileListImage: View {
var image: String?
var title: String
var customTitle: String?
var body: some View {
if let image = image, !image.isEmpty {
Image(systemName: image)
.frame(width: 24, height: 24)
} else {
// Fallback icon with gradient background based on title
FallbackIcon(title: customTitle ?? title, size: 24)
}
}
}
private struct SectionHeaderText: View {
let text: String
var body: some View {
Text(text).font(.headline).foregroundStyle(.black).textCase(.none).padding(.top, 20)
}
}
private struct ProfileItemTitle: View {
let title: String
let customTitle: String?
var body: some View {
if let customTitle = customTitle, !customTitle.isEmpty {
Text(customTitle).lineLimit(1)
} else {
Text(title).lineLimit(1)
}
}
}
private struct ProfileAvatar: View {
let image: String?
let name: String
var body: some View {
AsyncImage(url: URL(string: image ?? "")) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
FallbackIcon(title: name, size: 80)
@unknown default:
FallbackIcon(title: name, size: 80)
}
}
.scaledToFill()
.frame(width: 80, height: 80)
.clipShape(Circle())
}
}

View File

@ -1,21 +1,30 @@
import { useTypeScriptHappyCallback } from "@follow/hooks"
import { useSetAtom, useStore } from "jotai"
import type { PropsWithChildren } from "react"
import { forwardRef, useContext, useLayoutEffect, useState } from "react"
import {
forwardRef,
useContext,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react"
import type { ScrollView, ScrollViewProps, StyleProp, ViewStyle } from "react-native"
import { View } from "react-native"
import { findNodeHandle, View } from "react-native"
import type { SharedValue } from "react-native-reanimated"
import { useAnimatedScrollHandler } from "react-native-reanimated"
import { runOnJS, useAnimatedScrollHandler } from "react-native-reanimated"
import type { ReanimatedScrollEvent } from "react-native-reanimated/lib/typescript/hook/commonTypes"
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context"
import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks"
import { isScrollToEnd } from "@/src/lib/native"
import { useScreenIsInSheetModal } from "@/src/lib/navigation/hooks"
import { ScreenItemContext } from "@/src/lib/navigation/ScreenItemContext"
import { ReAnimatedScrollView } from "../../common/AnimatedComponents"
import type { InternalNavigationHeaderProps } from "../header/NavigationHeader"
import { InternalNavigationHeader } from "../header/NavigationHeader"
import { BottomTabBarBackgroundContext } from "../tabbar/contexts/BottomTabBarBackgroundContext"
import { getDefaultHeaderHeight } from "../utils"
import {
NavigationHeaderHeightContext,
@ -48,7 +57,7 @@ export const SafeNavigationScrollView = forwardRef<ScrollView, SafeNavigationScr
contentViewStyle,
...props
},
ref,
forwardedRef,
) => {
const insets = useSafeAreaInsets()
const tabBarHeight = useBottomTabBarHeight()
@ -60,12 +69,25 @@ export const SafeNavigationScrollView = forwardRef<ScrollView, SafeNavigationScr
)
const screenCtxValue = useContext(ScreenItemContext)
const ref = useRef<ScrollView>(null)
useImperativeHandle(forwardedRef, () => ref.current!)
const { opacity } = useContext(BottomTabBarBackgroundContext)
function checkScrollToBottom() {
const handle = findNodeHandle(ref.current!)
if (!handle) {
return
}
isScrollToEnd(handle).then((isEnd) => {
opacity.value = isEnd ? 0 : 1
})
}
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
if (reanimatedScrollY) {
reanimatedScrollY.value = event.contentOffset.y
}
runOnJS(checkScrollToBottom)()
screenCtxValue.reAnimatedScrollY.value = event.contentOffset.y
},
})
@ -85,6 +107,7 @@ export const SafeNavigationScrollView = forwardRef<ScrollView, SafeNavigationScr
onLayout={useTypeScriptHappyCallback(
(e) => {
screenCtxValue.scrollViewHeight.value = e.nativeEvent.layout.height - headerHeight
checkScrollToBottom()
},
[screenCtxValue.scrollViewHeight, headerHeight],
)}

View File

@ -9,6 +9,7 @@ interface NativeModule {
}>
previewImage: (images: string[]) => void
scrollToTop: (reactTag: number) => void
isScrollToEnd: (reactTag: number) => Promise<boolean>
}
const nativeModule = requireNativeModule("Helper") as NativeModule
export const openLink = (url: string, onDismiss?: () => void) => {
@ -37,3 +38,7 @@ export const showIntelligenceGlowEffect = () => {
export const hideIntelligenceGlowEffect = () => {
requireNativeModule("AppleIntelligenceGlowEffect").hide()
}
export const isScrollToEnd = async (tag: number) => {
return nativeModule.isScrollToEnd(tag)
}

View File

@ -14,3 +14,7 @@ export const showIntelligenceGlowEffect = () => {
}
export const hideIntelligenceGlowEffect = () => {}
export const isScrollToEnd = async (_reactTag: number) => {
return false
}

View File

@ -1,41 +0,0 @@
import { useContext } from "react"
import { useAnimatedReaction } from "react-native-reanimated"
import { BottomTabBarBackgroundContext } from "@/src/components/layouts/tabbar/contexts/BottomTabBarBackgroundContext"
import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks"
import { ScreenItemContext } from "../ScreenItemContext"
// FIXME
export const CalculateTabBarOpacity = () => {
const { scrollViewContentHeight, scrollViewHeight, reAnimatedScrollY } =
useContext(ScreenItemContext)
const { opacity } = useContext(BottomTabBarBackgroundContext)
const tabbarHeight = useBottomTabBarHeight()
useAnimatedReaction(
() => {
// Calculate how close we are to the bottom of the content
const distanceFromBottom =
scrollViewContentHeight.value -
scrollViewHeight.value -
reAnimatedScrollY.value -
tabbarHeight
// Define a threshold for when to start fading (in pixels)
const fadeThreshold = 50
// If we're within the threshold distance from the bottom, calculate opacity
if (distanceFromBottom <= fadeThreshold) {
// Linear interpolation: 0 at bottom, 1 at threshold
return Math.max(0, distanceFromBottom / fadeThreshold)
}
// Otherwise, keep the tab bar fully visible
return 1
},
(opacityValue) => {
opacity.value = opacityValue
},
)
return null
}

View File

@ -7,13 +7,15 @@ import type {
import { FlashList, MasonryFlashList } from "@shopify/flash-list"
import * as Haptics from "expo-haptics"
import type { ElementRef, RefObject } from "react"
import { forwardRef, useCallback, useContext } from "react"
import { forwardRef, useCallback, useContext, useImperativeHandle, useRef } from "react"
import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"
import { RefreshControl } from "react-native"
import { findNodeHandle, RefreshControl } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useColor } from "react-native-uikit-colors"
import { BottomTabBarBackgroundContext } from "@/src/components/layouts/tabbar/contexts/BottomTabBarBackgroundContext"
import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks"
import { isScrollToEnd } from "@/src/lib/native"
import { ScreenItemContext } from "@/src/lib/navigation/ScreenItemContext"
import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight"
import { usePrefetchSubscription } from "@/src/store/subscription/hooks"
@ -29,21 +31,33 @@ type Props = {
export const TimelineSelectorList = forwardRef<
FlashList<any>,
Props & Omit<FlashListProps<any>, "onRefresh">
>(({ onRefresh, isRefetching, ...props }, ref) => {
>(({ onRefresh, isRefetching, ...props }, forwardedRef) => {
const ref = useRef<FlashList<any>>(null)
useImperativeHandle(forwardedRef, () => ref.current!)
const { refetch: unreadRefetch } = usePrefetchUnread()
const { refetch: subscriptionRefetch } = usePrefetchSubscription()
const headerHeight = useHeaderHeight()
const { reAnimatedScrollY, scrollViewHeight, scrollViewContentHeight } =
useContext(ScreenItemContext)!
const { opacity } = useContext(BottomTabBarBackgroundContext)
const checkScrollToBottom = useCallback(() => {
const handle = findNodeHandle(ref.current!)
if (!handle) {
return
}
isScrollToEnd(handle).then((isEnd) => {
opacity.value = isEnd ? 0 : 1
})
}, [opacity])
const onScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
props.onScroll?.(e)
reAnimatedScrollY.value = e.nativeEvent.contentOffset.y
checkScrollToBottom()
},
[props, reAnimatedScrollY],
[props, reAnimatedScrollY, checkScrollToBottom],
)
const tabBarHeight = useBottomTabBarHeight()

View File

@ -1,117 +0,0 @@
import type { FeedViewType } from "@follow/constants"
import { requireNativeView } from "expo"
import { useEffect, useMemo } from "react"
import { View } from "react-native"
import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformActivityIndicator"
import type { apiClient } from "@/src/lib/api-fetch"
import type { NavigationControllerView } from "@/src/lib/navigation/types"
import { toast } from "@/src/lib/toast"
import { useShareSubscription } from "@/src/modules/settings/hooks/useShareSubscription"
import type { FeedModel } from "@/src/store/feed/types"
import type { ListModel } from "@/src/store/list/store"
import { useWhoami } from "@/src/store/user/hooks"
const ProfileView = requireNativeView("ProfileView")
type Subscription = Awaited<ReturnType<typeof apiClient.subscriptions.$get>>["data"][number]
export const ProfileScreen: NavigationControllerView<{
userId: string
}> = ({ userId }) => {
const whoami = useWhoami()
if (!whoami) {
return null
}
return <ProfileScreenImpl userId={userId || whoami?.id} />
}
function ProfileScreenImpl(props: { userId: string }) {
const {
data: subscriptions,
isLoading,
isError,
} = useShareSubscription({
userId: props.userId,
})
useEffect(() => {
if (isError) {
toast.error("Failed to fetch subscriptions")
}
}, [isError])
return (
<View className="bg-system-grouped-background flex-1">
{isLoading && <PlatformActivityIndicator className="mt-24" size={28} />}
{!isLoading && subscriptions && <SubscriptionList subscriptions={subscriptions.data} />}
</View>
)
}
type PickedListModel = Pick<ListModel, "id" | "title" | "image" | "description" | "view"> & {
customTitle?: string | null
}
type PickedFeedModel = Pick<
FeedModel,
"id" | "title" | "description" | "siteUrl" | "url" | "image"
> & {
customTitle?: string | null
view: FeedViewType
}
const SubscriptionList = ({ subscriptions }: { subscriptions: Subscription[] }) => {
const { lists, feeds, groupedFeeds } = useMemo(() => {
const lists = [] as PickedListModel[]
const feeds = [] as PickedFeedModel[]
const groupedFeeds = {} as Record<string, PickedFeedModel[]>
for (const subscription of subscriptions) {
if ("listId" in subscription) {
lists.push({
id: subscription.listId,
title: subscription.lists.title!,
image: subscription.lists.image!,
description: subscription.lists.description!,
view: subscription.lists.view,
customTitle: subscription.title,
})
continue
}
if ("feedId" in subscription && "feeds" in subscription) {
const feed = {
id: subscription.feedId,
title: subscription.feeds.title!,
image: subscription.feeds.image!,
description: subscription.feeds.description!,
siteUrl: subscription.feeds.siteUrl!,
url: subscription.feeds.url!,
view: subscription.view as FeedViewType,
customTitle: subscription.title,
}
if (subscription.category) {
groupedFeeds[subscription.category] = [
...(groupedFeeds[subscription.category] || []),
feed,
]
} else {
feeds.push(feed)
}
}
}
return { lists, feeds, groupedFeeds }
}, [subscriptions])
const user = useWhoami()
return (
<ProfileView
style={{ flex: 1 }}
payload={JSON.stringify({
user,
profile: { lists, feeds, groupedFeeds },
})}
/>
)
}