feat: optimize user profile modal style
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
70a5b8f8ed
commit
6c325d7cb8
|
|
@ -76,5 +76,8 @@
|
|||
"i18n-ally.namespace": true,
|
||||
"i18n-ally.pathMatcher": "{namespaces}/{locale}.json",
|
||||
"lldb.library": "/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB",
|
||||
"lldb.launch.expressions": "native"
|
||||
"lldb.launch.expressions": "native",
|
||||
"[swift]": {
|
||||
"editor.defaultFormatter": "swiftlang.swift-vscode"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||
{
|
||||
ios: {
|
||||
useFrameworks: "static",
|
||||
deploymentTarget: "17.0",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
"GaleriaAccessoryModule",
|
||||
"TabBarModule",
|
||||
"TabScreenModule",
|
||||
"TabBarPortalModule"
|
||||
"TabBarPortalModule",
|
||||
"ProfileViewModule"
|
||||
]
|
||||
},
|
||||
"android": {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ Pod::Spec.new do |s|
|
|||
s.author = package['author']
|
||||
s.homepage = package['homepage']
|
||||
s.platforms = {
|
||||
:ios => '15.1',
|
||||
:tvos => '15.1'
|
||||
:ios => '17.0',
|
||||
:tvos => '17.0'
|
||||
}
|
||||
s.swift_version = '5.4'
|
||||
s.source = { git: 'https://github.com/RSSNext/follow' }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import Foundation
|
||||
import ExpoModulesCore
|
||||
|
||||
struct ProfileData: Codable {
|
||||
var lists: [ProfileList]
|
||||
var feeds: [ProfileFeed]
|
||||
var groupedFeeds: [String: [ProfileFeed]]
|
||||
|
||||
static var mockData: ProfileData {
|
||||
let decoder = JSONDecoder()
|
||||
guard let url = Bundle.main.url(forResource: "Profile", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let profileData = try? decoder.decode(ProfileData.self, from: data)
|
||||
else {
|
||||
// Return empty data if decoding fails
|
||||
return ProfileData(lists: [], feeds: [], groupedFeeds: [:])
|
||||
}
|
||||
return profileData
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfileList: Codable, Identifiable {
|
||||
var id: String
|
||||
var title: String
|
||||
var image: String?
|
||||
var description: String?
|
||||
var view: FeedViewType
|
||||
var customTitle: String?
|
||||
}
|
||||
|
||||
struct ProfileFeed: Codable, Identifiable {
|
||||
var id: String
|
||||
var title: String
|
||||
var image: String?
|
||||
var description: String?
|
||||
var siteUrl: String
|
||||
var url: String
|
||||
var view: FeedViewType
|
||||
var customTitle: String?
|
||||
}
|
||||
|
||||
enum FeedViewType: Int, Codable {
|
||||
case Article = 0
|
||||
case SocialMedia = 1
|
||||
case Image = 2
|
||||
case Video = 3
|
||||
case Audio = 4
|
||||
case Notification = 5
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
//
|
||||
// UserData.swift
|
||||
// SwiftUIDemo
|
||||
//
|
||||
// Created by Innei on 2025/3/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct UserData: Codable {
|
||||
var id: String
|
||||
var name: String
|
||||
var email: String
|
||||
var emailVerified: Bool
|
||||
var image: String
|
||||
var createdAt: String
|
||||
var updatedAt: String
|
||||
var twoFactorEnabled: Bool
|
||||
var isAnonymous: Bool?
|
||||
var handle: String
|
||||
|
||||
static var mockData: UserData {
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
guard let url = Bundle.main.url(forResource: "User", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let profileData = try? decoder.decode(UserData.self, from: data)
|
||||
else {
|
||||
// Return empty data if decoding fails
|
||||
return UserData(
|
||||
id: "", name: "", email: "", emailVerified: false, image: "", createdAt: "", updatedAt: "",
|
||||
twoFactorEnabled: false, isAnonymous: false, handle: "")
|
||||
}
|
||||
return profileData
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
//
|
||||
// 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),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
//
|
||||
// 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()
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
//
|
||||
// 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)
|
||||
.listSectionSpacing(0)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import type { FeedViewType } from "@follow/constants"
|
||||
import { requireNativeView } from "expo"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { ActivityIndicator, View } from "react-native"
|
||||
|
||||
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 && <ActivityIndicator 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 },
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
import type { FeedViewType } from "@follow/constants"
|
||||
import { cn } from "@follow/utils"
|
||||
import { Fragment, useCallback, useEffect, useMemo } from "react"
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Image,
|
||||
Share,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
|
|
@ -26,6 +24,15 @@ import {
|
|||
UINavigationHeaderActionButton,
|
||||
} from "@/src/components/layouts/header/NavigationHeader"
|
||||
import { getDefaultHeaderHeight } from "@/src/components/layouts/utils"
|
||||
import {
|
||||
GROUPED_ICON_TEXT_GAP,
|
||||
GROUPED_LIST_ITEM_PADDING,
|
||||
GROUPED_LIST_MARGIN,
|
||||
} from "@/src/components/ui/grouped/constants"
|
||||
import {
|
||||
GroupedInsetListCard,
|
||||
GroupedInsetListSectionHeader,
|
||||
} from "@/src/components/ui/grouped/GroupedList"
|
||||
import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon"
|
||||
import type { FeedIconRequiredFeed } from "@/src/components/ui/icon/feed-icon"
|
||||
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
|
||||
|
|
@ -196,52 +203,52 @@ const SubscriptionList = ({ subscriptions }: { subscriptions: Subscription[] })
|
|||
<Fragment>
|
||||
<SectionHeader title="Lists" />
|
||||
|
||||
<FlatList
|
||||
scrollEnabled={false}
|
||||
data={lists}
|
||||
renderItem={renderListItems}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
/>
|
||||
<GroupedInsetListCard>
|
||||
<FlatList
|
||||
scrollEnabled={false}
|
||||
data={lists}
|
||||
renderItem={renderListItems}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
/>
|
||||
</GroupedInsetListCard>
|
||||
</Fragment>
|
||||
)}
|
||||
{hasFeeds && (
|
||||
<View className="mt-4">
|
||||
<SectionHeader title="Feeds" />
|
||||
{Object.entries(groupedFeeds).map(([category, feeds], index) => (
|
||||
{Object.entries(groupedFeeds).map(([category, feeds]) => (
|
||||
<Fragment key={category}>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-secondary-label mb-2 ml-3 text-sm font-medium",
|
||||
index !== 0 ? "mt-6" : "",
|
||||
)}
|
||||
>
|
||||
{category}
|
||||
</Text>
|
||||
<FlatList
|
||||
scrollEnabled={false}
|
||||
data={feeds}
|
||||
renderItem={renderFeedItems}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
/>
|
||||
<GroupedInsetListSectionHeader label={category} marginSize="small" />
|
||||
<GroupedInsetListCard>
|
||||
<FlatList
|
||||
scrollEnabled={false}
|
||||
data={feeds}
|
||||
renderItem={renderFeedItems}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
/>
|
||||
</GroupedInsetListCard>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
<Text className="text-secondary-label mb-2 ml-3 mt-6 text-sm font-medium">
|
||||
Uncategorized Feeds
|
||||
</Text>
|
||||
<FlatList
|
||||
scrollEnabled={false}
|
||||
data={feeds}
|
||||
renderItem={renderFeedItems}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
/>
|
||||
<GroupedInsetListSectionHeader label="Uncategorized Feeds" marginSize="small" />
|
||||
<GroupedInsetListCard>
|
||||
<FlatList
|
||||
scrollEnabled={false}
|
||||
data={feeds}
|
||||
renderItem={renderFeedItems}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
/>
|
||||
</GroupedInsetListCard>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const renderListItems = ({ item }: { item: PickedListModel }) => (
|
||||
<View className="bg-secondary-system-grouped-background h-12 flex-row items-center px-3">
|
||||
<View
|
||||
className="bg-secondary-system-grouped-background h-12 flex-row items-center"
|
||||
style={{ paddingHorizontal: GROUPED_LIST_ITEM_PADDING }}
|
||||
>
|
||||
<View className="overflow-hidden rounded">
|
||||
{!!item.image && (
|
||||
<Image source={{ uri: item.image, width: 24, height: 24 }} resizeMode="cover" />
|
||||
|
|
@ -249,12 +256,17 @@ const renderListItems = ({ item }: { item: PickedListModel }) => (
|
|||
{!item.image && <FallbackIcon title={item.title} size={24} />}
|
||||
</View>
|
||||
|
||||
<Text className="text-text ml-2">{item.title}</Text>
|
||||
<Text className="text-text" style={{ marginLeft: GROUPED_ICON_TEXT_GAP }}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
const renderFeedItems = ({ item }: { item: PickedFeedModel }) => (
|
||||
<View className="bg-secondary-system-grouped-background h-12 flex-row items-center px-3">
|
||||
<View
|
||||
className="bg-secondary-system-grouped-background h-12 flex-row items-center"
|
||||
style={{ paddingHorizontal: GROUPED_LIST_ITEM_PADDING }}
|
||||
>
|
||||
<View className="overflow-hidden rounded">
|
||||
<FeedIcon
|
||||
feed={
|
||||
|
|
@ -270,20 +282,19 @@ const renderFeedItems = ({ item }: { item: PickedFeedModel }) => (
|
|||
size={24}
|
||||
/>
|
||||
</View>
|
||||
<Text className="text-text ml-2">{item.title}</Text>
|
||||
<Text className="text-text" style={{ marginLeft: GROUPED_ICON_TEXT_GAP }}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<View className="my-5 flex-row items-center justify-center gap-4">
|
||||
<View
|
||||
className="bg-secondary-label w-12 rounded-full"
|
||||
style={{ height: StyleSheet.hairlineWidth }}
|
||||
/>
|
||||
<Text className="text-secondary-label text-sm font-medium">{title}</Text>
|
||||
<View
|
||||
className="bg-secondary-label w-12 rounded-full"
|
||||
style={{ height: StyleSheet.hairlineWidth }}
|
||||
/>
|
||||
<View className="mb-2 mt-5" style={{ marginHorizontal: GROUPED_LIST_MARGIN }}>
|
||||
<Text
|
||||
className="text-label text-xl font-medium"
|
||||
style={{ marginLeft: GROUPED_LIST_ITEM_PADDING }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue