diff --git a/apps/mobile/native/expo-module.config.json b/apps/mobile/native/expo-module.config.json index 28e2400ae..904b0a42b 100644 --- a/apps/mobile/native/expo-module.config.json +++ b/apps/mobile/native/expo-module.config.json @@ -11,7 +11,6 @@ "TabBarModule", "TabScreenModule", "TabBarPortalModule", - "ProfileViewModule", "EnhancePagerViewModule", "EnhancePageViewModule" ] diff --git a/apps/mobile/native/ios/Modules/Helper/HelperModule.swift b/apps/mobile/native/ios/Modules/Helper/HelperModule.swift index 5f2eb5fc8..f607a5aa4 100644 --- a/apps/mobile/native/ios/Modules/Helper/HelperModule.swift +++ b/apps/mobile/native/ios/Modules/Helper/HelperModule.swift @@ -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(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(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) + } } - } } diff --git a/apps/mobile/native/ios/Modules/ProfileView/ProfileViewModule.swift b/apps/mobile/native/ios/Modules/ProfileView/ProfileViewModule.swift deleted file mode 100644 index f44ff685b..000000000 --- a/apps/mobile/native/ios/Modules/ProfileView/ProfileViewModule.swift +++ /dev/null @@ -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) - } - } -} diff --git a/apps/mobile/native/ios/Views/FallbackIconView.swift b/apps/mobile/native/ios/Views/FallbackIconView.swift deleted file mode 100644 index f0d9259f1..000000000 --- a/apps/mobile/native/ios/Views/FallbackIconView.swift +++ /dev/null @@ -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] = [ - 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), - ]) - } -} - diff --git a/apps/mobile/native/ios/Views/FeedIconView.swift b/apps/mobile/native/ios/Views/FeedIconView.swift deleted file mode 100644 index 8f5f6e56a..000000000 --- a/apps/mobile/native/ios/Views/FeedIconView.swift +++ /dev/null @@ -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() -} diff --git a/apps/mobile/native/ios/Views/ProfileView.swift b/apps/mobile/native/ios/Views/ProfileView.swift deleted file mode 100644 index aae3a361d..000000000 --- a/apps/mobile/native/ios/Views/ProfileView.swift +++ /dev/null @@ -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()) - - } -} diff --git a/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx b/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx index 0b4ef0cea..e9fb45703 100644 --- a/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx +++ b/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx @@ -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 { const insets = useSafeAreaInsets() const tabBarHeight = useBottomTabBarHeight() @@ -60,12 +69,25 @@ export const SafeNavigationScrollView = forwardRef(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 { screenCtxValue.scrollViewHeight.value = e.nativeEvent.layout.height - headerHeight + checkScrollToBottom() }, [screenCtxValue.scrollViewHeight, headerHeight], )} diff --git a/apps/mobile/src/lib/native/index.ios.ts b/apps/mobile/src/lib/native/index.ios.ts index 314555ac9..9cf99f8c9 100644 --- a/apps/mobile/src/lib/native/index.ios.ts +++ b/apps/mobile/src/lib/native/index.ios.ts @@ -9,6 +9,7 @@ interface NativeModule { }> previewImage: (images: string[]) => void scrollToTop: (reactTag: number) => void + isScrollToEnd: (reactTag: number) => Promise } 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) +} diff --git a/apps/mobile/src/lib/native/index.ts b/apps/mobile/src/lib/native/index.ts index 0c63582e2..de7b11ea5 100644 --- a/apps/mobile/src/lib/native/index.ts +++ b/apps/mobile/src/lib/native/index.ts @@ -14,3 +14,7 @@ export const showIntelligenceGlowEffect = () => { } export const hideIntelligenceGlowEffect = () => {} + +export const isScrollToEnd = async (_reactTag: number) => { + return false +} diff --git a/apps/mobile/src/lib/navigation/bottom-tab/CalculateTabBarOpacity.tsx b/apps/mobile/src/lib/navigation/bottom-tab/CalculateTabBarOpacity.tsx deleted file mode 100644 index 7411b68cc..000000000 --- a/apps/mobile/src/lib/navigation/bottom-tab/CalculateTabBarOpacity.tsx +++ /dev/null @@ -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 -} diff --git a/apps/mobile/src/modules/screen/TimelineSelectorList.tsx b/apps/mobile/src/modules/screen/TimelineSelectorList.tsx index 080573fc7..7693f7b57 100644 --- a/apps/mobile/src/modules/screen/TimelineSelectorList.tsx +++ b/apps/mobile/src/modules/screen/TimelineSelectorList.tsx @@ -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, Props & Omit, "onRefresh"> ->(({ onRefresh, isRefetching, ...props }, ref) => { +>(({ onRefresh, isRefetching, ...props }, forwardedRef) => { + const ref = useRef>(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) => { props.onScroll?.(e) reAnimatedScrollY.value = e.nativeEvent.contentOffset.y + checkScrollToBottom() }, - [props, reAnimatedScrollY], + [props, reAnimatedScrollY, checkScrollToBottom], ) const tabBarHeight = useBottomTabBarHeight() diff --git a/apps/mobile/src/screens/(modal)/profile.ios.old.tsx b/apps/mobile/src/screens/(modal)/profile.ios.old.tsx deleted file mode 100644 index f1bf2696a..000000000 --- a/apps/mobile/src/screens/(modal)/profile.ios.old.tsx +++ /dev/null @@ -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>["data"][number] - -export const ProfileScreen: NavigationControllerView<{ - userId: string -}> = ({ userId }) => { - const whoami = useWhoami() - - if (!whoami) { - return null - } - return -} - -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 ( - - {isLoading && } - {!isLoading && subscriptions && } - - ) -} - -type PickedListModel = Pick & { - 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 - - 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 ( - - ) -}