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:
parent
db5c4c04dd
commit
4fc8ec3f42
|
|
@ -11,7 +11,6 @@
|
|||
"TabBarModule",
|
||||
"TabScreenModule",
|
||||
"TabBarPortalModule",
|
||||
"ProfileViewModule",
|
||||
"EnhancePagerViewModule",
|
||||
"EnhancePageViewModule"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,3 +14,7 @@ export const showIntelligenceGlowEffect = () => {
|
|||
}
|
||||
|
||||
export const hideIntelligenceGlowEffect = () => {}
|
||||
|
||||
export const isScrollToEnd = async (_reactTag: number) => {
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue