├── .cursor └── rules │ └── swiftui.mdc ├── .github └── workflows │ └── icy_sky.yml ├── .gitignore ├── .mise.toml ├── .vscode ├── launch.json └── settings.json ├── App ├── AppDestinations.swift ├── AppTabsRoot.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Frame 11.png │ │ ├── glyph.png │ │ └── icon.png │ └── Contents.json ├── IcySky.entitlements ├── IcySkyApp.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json └── UINavigationControllerExt.swift ├── IcySky-Info.plist ├── IcySky.code-workspace ├── IcySky.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── IcySky.xcscheme ├── Images ├── glyph.png ├── image1.png └── image2.png ├── LICENSE ├── PackageTests.xctestplan ├── Packages ├── Features │ ├── .gitignore │ ├── .swiftpm │ │ └── xcode │ │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ ├── AuthUI.xcscheme │ │ │ ├── DesignSystem.xcscheme │ │ │ ├── DesignSystemTests.xcscheme │ │ │ ├── FeedUI.xcscheme │ │ │ └── FeedUITests.xcscheme │ ├── Package.resolved │ ├── Package.swift │ ├── Sources │ │ ├── AuthUI │ │ │ └── AuthView.swift │ │ ├── DesignSystem │ │ │ ├── Base │ │ │ │ ├── Colors.swift │ │ │ │ ├── Colors.xcassets │ │ │ │ │ ├── Contents.json │ │ │ │ │ ├── shadowPrimary.colorset │ │ │ │ │ │ └── Contents.json │ │ │ │ │ └── shadowSecondary.colorset │ │ │ │ │ │ └── Contents.json │ │ │ │ ├── Container.swift │ │ │ │ ├── GlowingRoundedRectangle.swift │ │ │ │ ├── Pill.swift │ │ │ │ └── PillButtonStyle.swift │ │ │ ├── Components │ │ │ │ ├── CustomSpacingLabel.swift │ │ │ │ └── TabBarView.swift │ │ │ └── Header │ │ │ │ └── HeaderVIew.swift │ │ ├── FeedUI │ │ │ ├── List │ │ │ │ ├── FeedsListDividerView.swift │ │ │ │ ├── FeedsListErrorView.swift │ │ │ │ ├── FeedsListFilter.swift │ │ │ │ ├── FeedsListRecentSection.swift │ │ │ │ ├── FeedsListSearchField.swift │ │ │ │ ├── FeedsListTitleView.swift │ │ │ │ └── FeedsListView.swift │ │ │ └── Row │ │ │ │ ├── FeedCompactRowView.swift │ │ │ │ ├── FeedRowView.swift │ │ │ │ ├── RecentlyViewedFeedRowView.swift │ │ │ │ └── TimelineFeedRowView.swift │ │ ├── MediaUI │ │ │ └── FullScreenMediaView.swift │ │ ├── NotificationsUI │ │ │ ├── NotificationsGroup.swift │ │ │ ├── NotificationsListView.swift │ │ │ └── Rows │ │ │ │ ├── GroupedNotificationRow.swift │ │ │ │ ├── NotificationIconView.swift │ │ │ │ └── SingleNotificationRow.swift │ │ ├── PostUI │ │ │ ├── Detail │ │ │ │ └── PostDetailView.swift │ │ │ ├── List │ │ │ │ ├── Base │ │ │ │ │ ├── PostsListView.swift │ │ │ │ │ ├── PostsListViewDatasource.swift │ │ │ │ │ └── PostsListViewState.swift │ │ │ │ ├── PostsFeedView.swift │ │ │ │ ├── PostsLikesView.swift │ │ │ │ ├── PostsProfileView.swift │ │ │ │ └── PostsTimelineView.swift │ │ │ └── Row │ │ │ │ ├── PostRowActionsView.swift │ │ │ │ ├── PostRowBodyView.swift │ │ │ │ ├── PostRowEmbedExternalView.swift │ │ │ │ ├── PostRowEmbedQuoteView.swift │ │ │ │ ├── PostRowEmbedView.swift │ │ │ │ ├── PostRowImagesView.swift │ │ │ │ └── PostRowView.swift │ │ ├── ProfileUI │ │ │ ├── CurrentUserView.swift │ │ │ └── ProfileView.swift │ │ └── SettingsUI │ │ │ └── SettingsView.swift │ └── Tests │ │ ├── DesignSystemTests │ │ └── HeaderViewTests.swift │ │ └── FeedUITests │ │ └── FeedsListTitleViewTests.swift └── Model │ ├── .gitignore │ ├── .swiftpm │ └── xcode │ │ └── xcshareddata │ │ └── xcschemes │ │ ├── Auth.xcscheme │ │ ├── AuthTests.xcscheme │ │ └── Network.xcscheme │ ├── Package.resolved │ ├── Package.swift │ ├── Sources │ ├── Auth │ │ └── Auth.swift │ ├── Destinations │ │ ├── AppRouter.swift │ │ ├── AppTab.swift │ │ ├── RouterDestination.swift │ │ └── SheetDestination.swift │ ├── Models │ │ ├── FeedItem.swift │ │ ├── Media.swift │ │ ├── PorfileBasicViewExt.swift │ │ ├── PostContext.swift │ │ ├── PostItem.swift │ │ ├── PostsProfileViewFilter.swift │ │ ├── Profile.swift │ │ ├── RecentFeedItem.swift │ │ └── Utils │ │ │ ├── Date.swift │ │ │ └── DateFormatterCache.swift │ ├── Network │ │ └── BSKyClient.swift │ └── User │ │ └── CurrentUser.swift │ └── Tests │ └── AuthTests │ └── AuthTests.swift ├── README.md ├── Tuist.swift ├── buildServer.json └── ci_scripts └── ci_post_clone.sh /.cursor/rules/swiftui.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Rules for IcySky, a SwiftUI project for a bluesky client 3 | globs: 4 | --- 5 | - This is a mostly a pure SwiftUI app. 6 | - It target iOS 18 so you can use the latest SwiftUI and iOS API 7 | - This is a client for the social network BlueSky. 8 | - It use the framework @ATPRotoKit, imported as a Swift Package to make all queries to the ATProtocol / API of BlueSky. Please refer to 9 | - ATProtoKit is linked in this workspace in @ATProtoKit.swift, look at the parent folder of this file. 10 | - The app is separated into two main packages undert the /packages folder. 11 | - The Features package, you can look at @Package.swift for all the libraries it contains. 12 | - The Features package is used mostly for the frontend libraries, and each feature of the app is in its own package. 13 | - There is also shared packages that can be imported in features libraries or directly in the app, like DesignSystem. 14 | - The Model package, you can look at @Package.swift for all the libraries it contains. 15 | - The Model package contain libraries such as Auth, base models, Router and everything for networking, including auth. 16 | - The root of the app is in @IcySkyApp.swift 17 | - There is very few files at the root of the project, everything should be in its own package. 18 | - The router of the app is delcared in @RouterDestination.swift and implemented as an extension at the app level in @AppRouter.swift 19 | -------------------------------------------------------------------------------- /.github/workflows/icy_sky.yml: -------------------------------------------------------------------------------- 1 | name: IcySky 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | test-model: 15 | name: Test Model Package 16 | runs-on: macos-15 17 | timeout-minutes: 30 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: jdx/mise-action@v2 21 | - name: Select Xcode 22 | run: sudo xcode-select -switch /Applications/Xcode_16.1.app 23 | - name: Skip Xcode Macro Fingerprint Validation 24 | run: defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES 25 | - name: Test Model Package 26 | run: swift test --package-path Packages/Model 27 | 28 | build: 29 | name: Build IcySky 30 | runs-on: macos-15 31 | timeout-minutes: 50 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: jdx/mise-action@v2 35 | - name: Select Xcode 36 | run: sudo xcode-select -switch /Applications/Xcode_16.1.app 37 | - name: Skip Xcode Macro Fingerprint Validation 38 | run: defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES 39 | - name: Build IcySky 40 | run: xcodebuild -scheme IcySky -destination 'platform=iOS Simulator,name=iPhone 16' -configuration "Debug" build 41 | - name: Share IcySky 42 | run: mise x -- tuist share IcySky --configuration Debug --platforms ios 43 | env: 44 | TUIST_CONFIG_TOKEN: ${{ secrets.TUIST_CONFIG_TOKEN }} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Obj-C/Swift specific 9 | *.hmap 10 | 11 | ## App packaging 12 | *.ipa 13 | *.dSYM.zip 14 | *.dSYM 15 | 16 | ## Playgrounds 17 | timeline.xctimeline 18 | playground.xcworkspace 19 | 20 | # Swift Package Manager 21 | # 22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 23 | # Packages/ 24 | # Package.pins 25 | # Package.resolved 26 | # *.xcodeproj 27 | # 28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 29 | # hence it is not needed unless you have added a package configuration file to your project 30 | # .swiftpm 31 | 32 | .build/ 33 | 34 | # CocoaPods 35 | # 36 | # We recommend against adding the Pods directory to your .gitignore. However 37 | # you should judge for yourself, the pros and cons are mentioned at: 38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 39 | # 40 | # Pods/ 41 | # 42 | # Add this line if you want to avoid checking in source code from the Xcode workspace 43 | # *.xcworkspace 44 | 45 | # Carthage 46 | # 47 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 48 | # Carthage/Checkouts 49 | 50 | Carthage/Build/ 51 | 52 | # fastlane 53 | # 54 | # It is recommended to not store the screenshots in the git repo. 55 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 56 | # For more information about the recommended setup visit: 57 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 58 | 59 | fastlane/report.xml 60 | fastlane/Preview.html 61 | fastlane/screenshots/**/*.png 62 | fastlane/test_output 63 | 64 | .DS_Store 65 | ._.DS_Store 66 | **/.DS_Store 67 | **/._.DS_Store 68 | buildServer.json 69 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | tuist = "4.35.0" 3 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "sweetpad-lldb", 9 | "request": "launch", 10 | "name": "Debug", 11 | "preLaunchTask": "sweetpad: launch" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[swift]": { 3 | "editor.defaultFormatter": "sweetpad.sweetpad", 4 | "editor.formatOnSave": true, 5 | "editor.tabSize": 2 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /App/AppDestinations.swift: -------------------------------------------------------------------------------- 1 | import AppRouter 2 | import Destinations 3 | import FeedUI 4 | import NotificationsUI 5 | import PostUI 6 | import ProfileUI 7 | import SwiftUI 8 | 9 | public struct AppDestinations: ViewModifier { 10 | public func body(content: Content) -> some View { 11 | content 12 | .navigationDestination(for: RouterDestination.self) { destination in 13 | switch destination { 14 | case .feed(let feedItem): 15 | PostsFeedView(feedItem: feedItem) 16 | case .post(let post): 17 | PostDetailView(post: post) 18 | case .timeline: 19 | PostsTimelineView() 20 | case .profile(let profile): 21 | ProfileView(profile: profile) 22 | case .profilePosts(let profile, let filter): 23 | PostsProfileView(profile: profile, filter: filter) 24 | case .profileLikes(let profile): 25 | PostsLikesView(profile: profile) 26 | } 27 | } 28 | } 29 | } 30 | 31 | extension View { 32 | public func withAppDestinations() -> some View { 33 | modifier(AppDestinations()) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /App/AppTabsRoot.swift: -------------------------------------------------------------------------------- 1 | import AppRouter 2 | import Auth 3 | import DesignSystem 4 | import Destinations 5 | import FeedUI 6 | import Models 7 | import Network 8 | import NotificationsUI 9 | import PostUI 10 | import ProfileUI 11 | import SettingsUI 12 | import SwiftUI 13 | 14 | struct AppTabRootView: View { 15 | @Environment(AppRouter.self) var router 16 | 17 | let tab: AppTab 18 | 19 | var body: some View { 20 | @Bindable var router = router 21 | 22 | GeometryReader { _ in 23 | NavigationStack(path: $router[tab]) { 24 | tab.rootView 25 | .navigationBarHidden(true) 26 | .withAppDestinations() 27 | .environment(\.currentTab, tab) 28 | } 29 | } 30 | .ignoresSafeArea() 31 | } 32 | } 33 | 34 | @MainActor 35 | extension AppTab { 36 | @ViewBuilder 37 | fileprivate var rootView: some View { 38 | switch self { 39 | case .feed: 40 | FeedsListView() 41 | case .profile: 42 | CurrentUserView() 43 | case .messages: 44 | HStack { 45 | Text("Messages view") 46 | } 47 | case .notification: 48 | NotificationsListView() 49 | case .settings: 50 | SettingsView() 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /App/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "filename" : "glyph.png", 17 | "idiom" : "universal", 18 | "platform" : "ios", 19 | "size" : "1024x1024" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "tinted" 26 | } 27 | ], 28 | "filename" : "Frame 11.png", 29 | "idiom" : "universal", 30 | "platform" : "ios", 31 | "size" : "1024x1024" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "16x16" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "16x16" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "32x32" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "32x32" 52 | }, 53 | { 54 | "idiom" : "mac", 55 | "scale" : "1x", 56 | "size" : "128x128" 57 | }, 58 | { 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "128x128" 62 | }, 63 | { 64 | "idiom" : "mac", 65 | "scale" : "1x", 66 | "size" : "256x256" 67 | }, 68 | { 69 | "idiom" : "mac", 70 | "scale" : "2x", 71 | "size" : "256x256" 72 | }, 73 | { 74 | "idiom" : "mac", 75 | "scale" : "1x", 76 | "size" : "512x512" 77 | }, 78 | { 79 | "idiom" : "mac", 80 | "scale" : "2x", 81 | "size" : "512x512" 82 | } 83 | ], 84 | "info" : { 85 | "author" : "xcode", 86 | "version" : 1 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/Frame 11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/IcySky/2c340642074caf3d5a8eefaf4786ee5e1d5276be/App/Assets.xcassets/AppIcon.appiconset/Frame 11.png -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/glyph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/IcySky/2c340642074caf3d5a8eefaf4786ee5e1d5276be/App/Assets.xcassets/AppIcon.appiconset/glyph.png -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/IcySky/2c340642074caf3d5a8eefaf4786ee5e1d5276be/App/Assets.xcassets/AppIcon.appiconset/icon.png -------------------------------------------------------------------------------- /App/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /App/IcySky.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /App/IcySkyApp.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import Auth 4 | import AuthUI 5 | import DesignSystem 6 | import Destinations 7 | import MediaUI 8 | import Models 9 | import Network 10 | import Nuke 11 | import NukeUI 12 | import SwiftUI 13 | import User 14 | import VariableBlur 15 | 16 | @main 17 | struct IcySkyApp: App { 18 | @Environment(\.scenePhase) var scenePhase 19 | 20 | @State var client: BSkyClient? 21 | @State var auth: Auth = .init() 22 | @State var currentUser: CurrentUser? 23 | @State var router: AppRouter = .init(initialTab: .feed) 24 | @State var isLoadingInitialSession: Bool = true 25 | @State var postDataControllerProvider: PostContextProvider = .init() 26 | 27 | init() { 28 | ImagePipeline.shared = ImagePipeline(configuration: .withDataCache) 29 | } 30 | 31 | var body: some Scene { 32 | WindowGroup { 33 | TabView(selection: $router.selectedTab) { 34 | if client != nil && currentUser != nil { 35 | ForEach(AppTab.allCases) { tab in 36 | AppTabRootView(tab: tab) 37 | .tag(tab) 38 | .toolbarVisibility(.hidden, for: .tabBar) 39 | } 40 | } else { 41 | ProgressView() 42 | .containerRelativeFrame([.horizontal, .vertical]) 43 | } 44 | } 45 | .environment(client) 46 | .environment(currentUser) 47 | .environment(auth) 48 | .environment(router) 49 | .environment(postDataControllerProvider) 50 | .modelContainer(for: RecentFeedItem.self) 51 | .sheet( 52 | item: $router.presentedSheet, 53 | content: { presentedSheet in 54 | switch presentedSheet { 55 | case .auth: 56 | AuthView() 57 | .environment(auth) 58 | case let .fullScreenMedia(images, preloadedImage, namespace): 59 | FullScreenMediaView( 60 | images: images, 61 | preloadedImage: preloadedImage, 62 | namespace: namespace 63 | ) 64 | } 65 | } 66 | ) 67 | .task(id: auth.sessionLastRefreshed) { 68 | if let newConfiguration = auth.configuration { 69 | await refreshEnvWith(configuration: newConfiguration) 70 | if router.presentedSheet == .auth { 71 | router.presentedSheet = nil 72 | } 73 | } else if auth.configuration == nil && !isLoadingInitialSession { 74 | router.presentedSheet = .auth 75 | } 76 | isLoadingInitialSession = false 77 | } 78 | .task(id: scenePhase) { 79 | if scenePhase == .active { 80 | await auth.refresh() 81 | } 82 | } 83 | .overlay( 84 | alignment: .top, 85 | content: { 86 | topFrostView 87 | } 88 | ) 89 | .overlay( 90 | alignment: .bottom, 91 | content: { 92 | ZStack(alignment: .center) { 93 | bottomFrostView 94 | 95 | if client != nil { 96 | TabBarView() 97 | .environment(router) 98 | .ignoresSafeArea(.keyboard) 99 | } 100 | } 101 | } 102 | ) 103 | .ignoresSafeArea(.keyboard) 104 | } 105 | } 106 | 107 | private var topFrostView: some View { 108 | VariableBlurView( 109 | maxBlurRadius: 10, 110 | direction: .blurredTopClearBottom 111 | ) 112 | .frame(height: 70) 113 | .ignoresSafeArea() 114 | .overlay(alignment: .top) { 115 | LinearGradient( 116 | colors: [.purple.opacity(0.07), .indigo.opacity(0.07), .clear], 117 | startPoint: .top, 118 | endPoint: .bottom 119 | ) 120 | .frame(height: 70) 121 | .ignoresSafeArea() 122 | } 123 | } 124 | 125 | private var bottomFrostView: some View { 126 | VariableBlurView( 127 | maxBlurRadius: 10, 128 | direction: .blurredBottomClearTop 129 | ) 130 | .frame(height: 100) 131 | .offset(y: 40) 132 | .ignoresSafeArea() 133 | .overlay(alignment: .bottom) { 134 | LinearGradient( 135 | colors: [.purple.opacity(0.07), .indigo.opacity(0.07), .clear], 136 | startPoint: .bottom, 137 | endPoint: .top 138 | ) 139 | .frame(height: 100) 140 | .offset(y: 40) 141 | .ignoresSafeArea() 142 | } 143 | } 144 | 145 | private func refreshEnvWith(configuration: ATProtocolConfiguration) async { 146 | let client = await BSkyClient(configuration: configuration) 147 | self.client = client 148 | self.currentUser = await CurrentUser(client: client) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /App/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /App/UINavigationControllerExt.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UINavigationController: @retroactive UIGestureRecognizerDelegate { 4 | override open func viewDidLoad() { 5 | super.viewDidLoad() 6 | interactivePopGestureRecognizer?.delegate = self 7 | 8 | let gesture = UnidirectionalPanGestureRecognizer(direction: .horizontal) 9 | gesture.delegate = self 10 | if let targets = interactivePopGestureRecognizer?.value(forKey: "targets") { 11 | gesture.setValue(targets, forKey: "targets") 12 | view.addGestureRecognizer(gesture) 13 | } 14 | } 15 | 16 | public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 17 | return viewControllers.count > 1 18 | } 19 | } 20 | 21 | public class UnidirectionalPanGestureRecognizer: UIPanGestureRecognizer { 22 | public enum Direction { 23 | case horizontal 24 | case vertical 25 | } 26 | 27 | let direction: Direction 28 | 29 | public init(direction: Direction, target: Any? = nil, action: Selector? = nil) { 30 | self.direction = direction 31 | super.init(target: target, action: action) 32 | } 33 | 34 | override public func touchesMoved(_ touches: Set, with event: UIEvent) { 35 | super.touchesMoved(touches, with: event) 36 | 37 | if state == .began { 38 | let vel = velocity(in: view) 39 | switch direction { 40 | case .horizontal where abs(vel.y) > abs(vel.x): 41 | state = .cancelled 42 | case .vertical where abs(vel.x) > abs(vel.y): 43 | state = .cancelled 44 | default: 45 | break 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /IcySky-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ITSAppUsesNonExemptEncryption 6 | 7 | NSPhotoLibraryAddUsageDescription 8 | Save images to your photo library 9 | 10 | 11 | -------------------------------------------------------------------------------- /IcySky.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | }, 6 | { 7 | "path": "../../../../Library/Developer/Xcode/DerivedData/IcySky-ddbwzwfxthgsenftplwefdqobzne/SourcePackages/checkouts/ATProtoKit/Sources/ATProtoKit" 8 | } 9 | ], 10 | "settings": {} 11 | } 12 | -------------------------------------------------------------------------------- /IcySky.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 9F1552E62CEF35B200DD9F6E /* Auth in Frameworks */ = {isa = PBXBuildFile; productRef = 9F1552E52CEF35B200DD9F6E /* Auth */; }; 11 | 9F1552E82CEF35B200DD9F6E /* Models in Frameworks */ = {isa = PBXBuildFile; productRef = 9F1552E72CEF35B200DD9F6E /* Models */; }; 12 | 9F1552EA2CEF35B200DD9F6E /* Network in Frameworks */ = {isa = PBXBuildFile; productRef = 9F1552E92CEF35B200DD9F6E /* Network */; }; 13 | 9F1552ED2CEF35C000DD9F6E /* AuthUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9F1552EC2CEF35C000DD9F6E /* AuthUI */; }; 14 | 9F1552EF2CEF35C000DD9F6E /* DesignSystem in Frameworks */ = {isa = PBXBuildFile; productRef = 9F1552EE2CEF35C000DD9F6E /* DesignSystem */; }; 15 | 9F1552F12CEF35C000DD9F6E /* FeedUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9F1552F02CEF35C000DD9F6E /* FeedUI */; }; 16 | 9F1552F32CEF35C000DD9F6E /* PostUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9F1552F22CEF35C000DD9F6E /* PostUI */; }; 17 | 9F21F9B72CFDC288003B9250 /* MediaUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9F21F9B62CFDC288003B9250 /* MediaUI */; }; 18 | 9F21FACB2D00411D003B9250 /* ProfileUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9F21FACA2D00411D003B9250 /* ProfileUI */; }; 19 | 9F5892032CEB2E2D00798943 /* Network in Frameworks */ = {isa = PBXBuildFile; productRef = 9F5892022CEB2E2D00798943 /* Network */; }; 20 | 9F5892972CEC703B00798943 /* DesignSystem in Frameworks */ = {isa = PBXBuildFile; productRef = 9F5892962CEC703B00798943 /* DesignSystem */; }; 21 | 9F58939D2CEDF04200798943 /* Auth in Frameworks */ = {isa = PBXBuildFile; productRef = 9F58939C2CEDF04200798943 /* Auth */; }; 22 | 9F58939F2CEDF04200798943 /* Models in Frameworks */ = {isa = PBXBuildFile; productRef = 9F58939E2CEDF04200798943 /* Models */; }; 23 | 9FA556A32CF49E55008A62B4 /* FeedUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9FD63DDD2CEF1F5B00243D9A /* FeedUI */; }; 24 | 9FA557082CF61EE4008A62B4 /* NotificationsUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9FA557072CF61EE4008A62B4 /* NotificationsUI */; }; 25 | 9FAEE3BA2CF09BB400F86CDC /* SettingsUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9FAEE3B92CF09BB400F86CDC /* SettingsUI */; }; 26 | 9FD63DE02CEF1F5B00243D9A /* PostUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9FD63DDF2CEF1F5B00243D9A /* PostUI */; }; 27 | 9FD63DE22CEF20F500243D9A /* AuthUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9FD63DE12CEF20F500243D9A /* AuthUI */; }; 28 | /* End PBXBuildFile section */ 29 | 30 | /* Begin PBXFileReference section */ 31 | 9F2142232CE9DAA1004167D7 /* IcySky.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IcySky.app; sourceTree = BUILT_PRODUCTS_DIR; }; 32 | 9F5F5E102CEE46B200A4712C /* IcySky-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "IcySky-Info.plist"; sourceTree = ""; }; 33 | 9FAEE3A72CF0994400F86CDC /* Features */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Features; path = Packages/Features; sourceTree = ""; }; 34 | 9FAEE3A82CF0994C00F86CDC /* Model */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Model; path = Packages/Model; sourceTree = ""; }; 35 | 9FAEE3B72CF099E600F86CDC /* PackageTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = PackageTests.xctestplan; sourceTree = ""; }; 36 | /* End PBXFileReference section */ 37 | 38 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 39 | 9F2142252CE9DAA1004167D7 /* App */ = { 40 | isa = PBXFileSystemSynchronizedRootGroup; 41 | path = App; 42 | sourceTree = ""; 43 | }; 44 | /* End PBXFileSystemSynchronizedRootGroup section */ 45 | 46 | /* Begin PBXFrameworksBuildPhase section */ 47 | 9F2142202CE9DAA1004167D7 /* Frameworks */ = { 48 | isa = PBXFrameworksBuildPhase; 49 | buildActionMask = 2147483647; 50 | files = ( 51 | 9FA556A32CF49E55008A62B4 /* FeedUI in Frameworks */, 52 | 9F1552F12CEF35C000DD9F6E /* FeedUI in Frameworks */, 53 | 9FAEE3BA2CF09BB400F86CDC /* SettingsUI in Frameworks */, 54 | 9F1552F32CEF35C000DD9F6E /* PostUI in Frameworks */, 55 | 9F58939F2CEDF04200798943 /* Models in Frameworks */, 56 | 9F1552ED2CEF35C000DD9F6E /* AuthUI in Frameworks */, 57 | 9F1552EF2CEF35C000DD9F6E /* DesignSystem in Frameworks */, 58 | 9FA557082CF61EE4008A62B4 /* NotificationsUI in Frameworks */, 59 | 9F5892032CEB2E2D00798943 /* Network in Frameworks */, 60 | 9F1552E82CEF35B200DD9F6E /* Models in Frameworks */, 61 | 9F21F9B72CFDC288003B9250 /* MediaUI in Frameworks */, 62 | 9F58939D2CEDF04200798943 /* Auth in Frameworks */, 63 | 9F21FACB2D00411D003B9250 /* ProfileUI in Frameworks */, 64 | 9FD63DE02CEF1F5B00243D9A /* PostUI in Frameworks */, 65 | 9F5892972CEC703B00798943 /* DesignSystem in Frameworks */, 66 | 9F1552E62CEF35B200DD9F6E /* Auth in Frameworks */, 67 | 9FD63DE22CEF20F500243D9A /* AuthUI in Frameworks */, 68 | 9F1552EA2CEF35B200DD9F6E /* Network in Frameworks */, 69 | ); 70 | runOnlyForDeploymentPostprocessing = 0; 71 | }; 72 | /* End PBXFrameworksBuildPhase section */ 73 | 74 | /* Begin PBXGroup section */ 75 | 9F21421A2CE9DAA1004167D7 = { 76 | isa = PBXGroup; 77 | children = ( 78 | 9FAEE3B72CF099E600F86CDC /* PackageTests.xctestplan */, 79 | 9F5F5E102CEE46B200A4712C /* IcySky-Info.plist */, 80 | 9F2142252CE9DAA1004167D7 /* App */, 81 | 9FAEE3A82CF0994C00F86CDC /* Model */, 82 | 9FAEE3A72CF0994400F86CDC /* Features */, 83 | 9FAEE3B82CF09BB400F86CDC /* Frameworks */, 84 | 9F2142242CE9DAA1004167D7 /* Products */, 85 | ); 86 | sourceTree = ""; 87 | }; 88 | 9F2142242CE9DAA1004167D7 /* Products */ = { 89 | isa = PBXGroup; 90 | children = ( 91 | 9F2142232CE9DAA1004167D7 /* IcySky.app */, 92 | ); 93 | name = Products; 94 | sourceTree = ""; 95 | }; 96 | 9FAEE3B82CF09BB400F86CDC /* Frameworks */ = { 97 | isa = PBXGroup; 98 | children = ( 99 | ); 100 | name = Frameworks; 101 | sourceTree = ""; 102 | }; 103 | /* End PBXGroup section */ 104 | 105 | /* Begin PBXNativeTarget section */ 106 | 9F2142222CE9DAA1004167D7 /* IcySky */ = { 107 | isa = PBXNativeTarget; 108 | buildConfigurationList = 9F2142482CE9DAA3004167D7 /* Build configuration list for PBXNativeTarget "IcySky" */; 109 | buildPhases = ( 110 | 9F21421F2CE9DAA1004167D7 /* Sources */, 111 | 9F2142202CE9DAA1004167D7 /* Frameworks */, 112 | 9F2142212CE9DAA1004167D7 /* Resources */, 113 | ); 114 | buildRules = ( 115 | ); 116 | dependencies = ( 117 | ); 118 | fileSystemSynchronizedGroups = ( 119 | 9F2142252CE9DAA1004167D7 /* App */, 120 | ); 121 | name = IcySky; 122 | packageProductDependencies = ( 123 | 9F5892022CEB2E2D00798943 /* Network */, 124 | 9F5892962CEC703B00798943 /* DesignSystem */, 125 | 9F58939C2CEDF04200798943 /* Auth */, 126 | 9F58939E2CEDF04200798943 /* Models */, 127 | 9FD63DDD2CEF1F5B00243D9A /* FeedUI */, 128 | 9FD63DDF2CEF1F5B00243D9A /* PostUI */, 129 | 9FD63DE12CEF20F500243D9A /* AuthUI */, 130 | 9F1552E52CEF35B200DD9F6E /* Auth */, 131 | 9F1552E72CEF35B200DD9F6E /* Models */, 132 | 9F1552E92CEF35B200DD9F6E /* Network */, 133 | 9F1552EC2CEF35C000DD9F6E /* AuthUI */, 134 | 9F1552EE2CEF35C000DD9F6E /* DesignSystem */, 135 | 9F1552F02CEF35C000DD9F6E /* FeedUI */, 136 | 9F1552F22CEF35C000DD9F6E /* PostUI */, 137 | 9FAEE3B92CF09BB400F86CDC /* SettingsUI */, 138 | 9FA557072CF61EE4008A62B4 /* NotificationsUI */, 139 | 9F21F9B62CFDC288003B9250 /* MediaUI */, 140 | 9F21FACA2D00411D003B9250 /* ProfileUI */, 141 | ); 142 | productName = IcySky; 143 | productReference = 9F2142232CE9DAA1004167D7 /* IcySky.app */; 144 | productType = "com.apple.product-type.application"; 145 | }; 146 | /* End PBXNativeTarget section */ 147 | 148 | /* Begin PBXProject section */ 149 | 9F21421B2CE9DAA1004167D7 /* Project object */ = { 150 | isa = PBXProject; 151 | attributes = { 152 | BuildIndependentTargetsInParallel = 1; 153 | LastSwiftUpdateCheck = 1610; 154 | LastUpgradeCheck = 1610; 155 | TargetAttributes = { 156 | 9F2142222CE9DAA1004167D7 = { 157 | CreatedOnToolsVersion = 16.1; 158 | }; 159 | }; 160 | }; 161 | buildConfigurationList = 9F21421E2CE9DAA1004167D7 /* Build configuration list for PBXProject "IcySky" */; 162 | developmentRegion = en; 163 | hasScannedForEncodings = 0; 164 | knownRegions = ( 165 | en, 166 | Base, 167 | ); 168 | mainGroup = 9F21421A2CE9DAA1004167D7; 169 | minimizedProjectReferenceProxies = 1; 170 | packageReferences = ( 171 | ); 172 | preferredProjectObjectVersion = 77; 173 | productRefGroup = 9F2142242CE9DAA1004167D7 /* Products */; 174 | projectDirPath = ""; 175 | projectRoot = ""; 176 | targets = ( 177 | 9F2142222CE9DAA1004167D7 /* IcySky */, 178 | ); 179 | }; 180 | /* End PBXProject section */ 181 | 182 | /* Begin PBXResourcesBuildPhase section */ 183 | 9F2142212CE9DAA1004167D7 /* Resources */ = { 184 | isa = PBXResourcesBuildPhase; 185 | buildActionMask = 2147483647; 186 | files = ( 187 | ); 188 | runOnlyForDeploymentPostprocessing = 0; 189 | }; 190 | /* End PBXResourcesBuildPhase section */ 191 | 192 | /* Begin PBXSourcesBuildPhase section */ 193 | 9F21421F2CE9DAA1004167D7 /* Sources */ = { 194 | isa = PBXSourcesBuildPhase; 195 | buildActionMask = 2147483647; 196 | files = ( 197 | ); 198 | runOnlyForDeploymentPostprocessing = 0; 199 | }; 200 | /* End PBXSourcesBuildPhase section */ 201 | 202 | /* Begin XCBuildConfiguration section */ 203 | 9F2142462CE9DAA3004167D7 /* Debug */ = { 204 | isa = XCBuildConfiguration; 205 | buildSettings = { 206 | ALWAYS_SEARCH_USER_PATHS = NO; 207 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 208 | CLANG_ANALYZER_NONNULL = YES; 209 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 210 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 211 | CLANG_ENABLE_MODULES = YES; 212 | CLANG_ENABLE_OBJC_ARC = YES; 213 | CLANG_ENABLE_OBJC_WEAK = YES; 214 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 215 | CLANG_WARN_BOOL_CONVERSION = YES; 216 | CLANG_WARN_COMMA = YES; 217 | CLANG_WARN_CONSTANT_CONVERSION = YES; 218 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 219 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 220 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 221 | CLANG_WARN_EMPTY_BODY = YES; 222 | CLANG_WARN_ENUM_CONVERSION = YES; 223 | CLANG_WARN_INFINITE_RECURSION = YES; 224 | CLANG_WARN_INT_CONVERSION = YES; 225 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 226 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 227 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 228 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 229 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 230 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 231 | CLANG_WARN_STRICT_PROTOTYPES = YES; 232 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 233 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 234 | CLANG_WARN_UNREACHABLE_CODE = YES; 235 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 236 | COPY_PHASE_STRIP = NO; 237 | DEBUG_INFORMATION_FORMAT = dwarf; 238 | ENABLE_STRICT_OBJC_MSGSEND = YES; 239 | ENABLE_TESTABILITY = YES; 240 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 241 | GCC_C_LANGUAGE_STANDARD = gnu17; 242 | GCC_DYNAMIC_NO_PIC = NO; 243 | GCC_NO_COMMON_BLOCKS = YES; 244 | GCC_OPTIMIZATION_LEVEL = 0; 245 | GCC_PREPROCESSOR_DEFINITIONS = ( 246 | "DEBUG=1", 247 | "$(inherited)", 248 | ); 249 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 250 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 251 | GCC_WARN_UNDECLARED_SELECTOR = YES; 252 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 253 | GCC_WARN_UNUSED_FUNCTION = YES; 254 | GCC_WARN_UNUSED_VARIABLE = YES; 255 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 256 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 257 | MTL_FAST_MATH = YES; 258 | ONLY_ACTIVE_ARCH = YES; 259 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 260 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 261 | }; 262 | name = Debug; 263 | }; 264 | 9F2142472CE9DAA3004167D7 /* Release */ = { 265 | isa = XCBuildConfiguration; 266 | buildSettings = { 267 | ALWAYS_SEARCH_USER_PATHS = NO; 268 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 269 | CLANG_ANALYZER_NONNULL = YES; 270 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 271 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 272 | CLANG_ENABLE_MODULES = YES; 273 | CLANG_ENABLE_OBJC_ARC = YES; 274 | CLANG_ENABLE_OBJC_WEAK = YES; 275 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 276 | CLANG_WARN_BOOL_CONVERSION = YES; 277 | CLANG_WARN_COMMA = YES; 278 | CLANG_WARN_CONSTANT_CONVERSION = YES; 279 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 280 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 281 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 282 | CLANG_WARN_EMPTY_BODY = YES; 283 | CLANG_WARN_ENUM_CONVERSION = YES; 284 | CLANG_WARN_INFINITE_RECURSION = YES; 285 | CLANG_WARN_INT_CONVERSION = YES; 286 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 287 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 288 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 289 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 290 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 291 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 292 | CLANG_WARN_STRICT_PROTOTYPES = YES; 293 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 294 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 295 | CLANG_WARN_UNREACHABLE_CODE = YES; 296 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 297 | COPY_PHASE_STRIP = NO; 298 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 299 | ENABLE_NS_ASSERTIONS = NO; 300 | ENABLE_STRICT_OBJC_MSGSEND = YES; 301 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 302 | GCC_C_LANGUAGE_STANDARD = gnu17; 303 | GCC_NO_COMMON_BLOCKS = YES; 304 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 305 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 306 | GCC_WARN_UNDECLARED_SELECTOR = YES; 307 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 308 | GCC_WARN_UNUSED_FUNCTION = YES; 309 | GCC_WARN_UNUSED_VARIABLE = YES; 310 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 311 | MTL_ENABLE_DEBUG_INFO = NO; 312 | MTL_FAST_MATH = YES; 313 | SWIFT_COMPILATION_MODE = wholemodule; 314 | }; 315 | name = Release; 316 | }; 317 | 9F2142492CE9DAA3004167D7 /* Debug */ = { 318 | isa = XCBuildConfiguration; 319 | buildSettings = { 320 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 321 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 322 | CODE_SIGN_ENTITLEMENTS = App/IcySky.entitlements; 323 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; 324 | CODE_SIGN_STYLE = Automatic; 325 | CURRENT_PROJECT_VERSION = 1; 326 | DEVELOPMENT_ASSET_PATHS = "\"App/Preview Content\""; 327 | DEVELOPMENT_TEAM = Z6P74P6T99; 328 | ENABLE_HARDENED_RUNTIME = YES; 329 | ENABLE_PREVIEWS = YES; 330 | GENERATE_INFOPLIST_FILE = YES; 331 | INFOPLIST_FILE = "IcySky-Info.plist"; 332 | INFOPLIST_KEY_CFBundleDisplayName = "Icy Sky"; 333 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; 334 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 335 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 336 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 337 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 338 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 339 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 340 | INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; 341 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 342 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 343 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 344 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 345 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 346 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 347 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 348 | MACOSX_DEPLOYMENT_TARGET = 15.1; 349 | MARKETING_VERSION = 1.0; 350 | PRODUCT_BUNDLE_IDENTIFIER = com.thomasricouard.GlowSky; 351 | PRODUCT_NAME = "$(TARGET_NAME)"; 352 | SDKROOT = auto; 353 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 354 | SUPPORTS_MACCATALYST = NO; 355 | SWIFT_EMIT_LOC_STRINGS = YES; 356 | SWIFT_VERSION = 6.0; 357 | TARGETED_DEVICE_FAMILY = "1,2"; 358 | XROS_DEPLOYMENT_TARGET = 2.1; 359 | }; 360 | name = Debug; 361 | }; 362 | 9F21424A2CE9DAA3004167D7 /* Release */ = { 363 | isa = XCBuildConfiguration; 364 | buildSettings = { 365 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 366 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 367 | CODE_SIGN_ENTITLEMENTS = App/IcySky.entitlements; 368 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; 369 | CODE_SIGN_STYLE = Automatic; 370 | CURRENT_PROJECT_VERSION = 1; 371 | DEVELOPMENT_ASSET_PATHS = "\"App/Preview Content\""; 372 | DEVELOPMENT_TEAM = Z6P74P6T99; 373 | ENABLE_HARDENED_RUNTIME = YES; 374 | ENABLE_PREVIEWS = YES; 375 | GENERATE_INFOPLIST_FILE = YES; 376 | INFOPLIST_FILE = "IcySky-Info.plist"; 377 | INFOPLIST_KEY_CFBundleDisplayName = "Icy Sky"; 378 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; 379 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 380 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 381 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 382 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 383 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 384 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 385 | INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; 386 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 387 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 388 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 389 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 390 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 391 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 392 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 393 | MACOSX_DEPLOYMENT_TARGET = 15.1; 394 | MARKETING_VERSION = 1.0; 395 | PRODUCT_BUNDLE_IDENTIFIER = com.thomasricouard.GlowSky; 396 | PRODUCT_NAME = "$(TARGET_NAME)"; 397 | SDKROOT = auto; 398 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 399 | SUPPORTS_MACCATALYST = NO; 400 | SWIFT_EMIT_LOC_STRINGS = YES; 401 | SWIFT_VERSION = 6.0; 402 | TARGETED_DEVICE_FAMILY = "1,2"; 403 | XROS_DEPLOYMENT_TARGET = 2.1; 404 | }; 405 | name = Release; 406 | }; 407 | /* End XCBuildConfiguration section */ 408 | 409 | /* Begin XCConfigurationList section */ 410 | 9F21421E2CE9DAA1004167D7 /* Build configuration list for PBXProject "IcySky" */ = { 411 | isa = XCConfigurationList; 412 | buildConfigurations = ( 413 | 9F2142462CE9DAA3004167D7 /* Debug */, 414 | 9F2142472CE9DAA3004167D7 /* Release */, 415 | ); 416 | defaultConfigurationIsVisible = 0; 417 | defaultConfigurationName = Release; 418 | }; 419 | 9F2142482CE9DAA3004167D7 /* Build configuration list for PBXNativeTarget "IcySky" */ = { 420 | isa = XCConfigurationList; 421 | buildConfigurations = ( 422 | 9F2142492CE9DAA3004167D7 /* Debug */, 423 | 9F21424A2CE9DAA3004167D7 /* Release */, 424 | ); 425 | defaultConfigurationIsVisible = 0; 426 | defaultConfigurationName = Release; 427 | }; 428 | /* End XCConfigurationList section */ 429 | 430 | /* Begin XCSwiftPackageProductDependency section */ 431 | 9F1552E52CEF35B200DD9F6E /* Auth */ = { 432 | isa = XCSwiftPackageProductDependency; 433 | productName = Auth; 434 | }; 435 | 9F1552E72CEF35B200DD9F6E /* Models */ = { 436 | isa = XCSwiftPackageProductDependency; 437 | productName = Models; 438 | }; 439 | 9F1552E92CEF35B200DD9F6E /* Network */ = { 440 | isa = XCSwiftPackageProductDependency; 441 | productName = Network; 442 | }; 443 | 9F1552EC2CEF35C000DD9F6E /* AuthUI */ = { 444 | isa = XCSwiftPackageProductDependency; 445 | productName = AuthUI; 446 | }; 447 | 9F1552EE2CEF35C000DD9F6E /* DesignSystem */ = { 448 | isa = XCSwiftPackageProductDependency; 449 | productName = DesignSystem; 450 | }; 451 | 9F1552F02CEF35C000DD9F6E /* FeedUI */ = { 452 | isa = XCSwiftPackageProductDependency; 453 | productName = FeedUI; 454 | }; 455 | 9F1552F22CEF35C000DD9F6E /* PostUI */ = { 456 | isa = XCSwiftPackageProductDependency; 457 | productName = PostUI; 458 | }; 459 | 9F21F9B62CFDC288003B9250 /* MediaUI */ = { 460 | isa = XCSwiftPackageProductDependency; 461 | productName = MediaUI; 462 | }; 463 | 9F21FACA2D00411D003B9250 /* ProfileUI */ = { 464 | isa = XCSwiftPackageProductDependency; 465 | productName = ProfileUI; 466 | }; 467 | 9F5892022CEB2E2D00798943 /* Network */ = { 468 | isa = XCSwiftPackageProductDependency; 469 | productName = Network; 470 | }; 471 | 9F5892962CEC703B00798943 /* DesignSystem */ = { 472 | isa = XCSwiftPackageProductDependency; 473 | productName = DesignSystem; 474 | }; 475 | 9F58939C2CEDF04200798943 /* Auth */ = { 476 | isa = XCSwiftPackageProductDependency; 477 | productName = Auth; 478 | }; 479 | 9F58939E2CEDF04200798943 /* Models */ = { 480 | isa = XCSwiftPackageProductDependency; 481 | productName = Models; 482 | }; 483 | 9FA557072CF61EE4008A62B4 /* NotificationsUI */ = { 484 | isa = XCSwiftPackageProductDependency; 485 | productName = NotificationsUI; 486 | }; 487 | 9FAEE3B92CF09BB400F86CDC /* SettingsUI */ = { 488 | isa = XCSwiftPackageProductDependency; 489 | productName = SettingsUI; 490 | }; 491 | 9FD63DDD2CEF1F5B00243D9A /* FeedUI */ = { 492 | isa = XCSwiftPackageProductDependency; 493 | productName = FeedUI; 494 | }; 495 | 9FD63DDF2CEF1F5B00243D9A /* PostUI */ = { 496 | isa = XCSwiftPackageProductDependency; 497 | productName = PostUI; 498 | }; 499 | 9FD63DE12CEF20F500243D9A /* AuthUI */ = { 500 | isa = XCSwiftPackageProductDependency; 501 | productName = AuthUI; 502 | }; 503 | /* End XCSwiftPackageProductDependency section */ 504 | }; 505 | rootObject = 9F21421B2CE9DAA1004167D7 /* Project object */; 506 | } 507 | -------------------------------------------------------------------------------- /IcySky.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /IcySky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "38535089e7edf2a9253c1811932fb3a1dd23f25cb9e77d4815e0874b031ab622", 3 | "pins" : [ 4 | { 5 | "identity" : "approuter", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/Dimillian/AppRouter.git", 8 | "state" : { 9 | "revision" : "72f63f4312a231ab2706234b9e99c4b71651f38c", 10 | "version" : "1.0.2" 11 | } 12 | }, 13 | { 14 | "identity" : "atprotokit", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/dimillian/ATProtoKit", 17 | "state" : { 18 | "branch" : "remove-update", 19 | "revision" : "a817ae7c7757c89260df374f4b3cf575da6fcceb" 20 | } 21 | }, 22 | { 23 | "identity" : "keychain-swift", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/evgenyneu/keychain-swift", 26 | "state" : { 27 | "revision" : "5e1b02b6a9dac2a759a1d5dbc175c86bd192a608", 28 | "version" : "24.0.0" 29 | } 30 | }, 31 | { 32 | "identity" : "nuke", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/kean/Nuke", 35 | "state" : { 36 | "revision" : "0ead44350d2737db384908569c012fe67c421e4d", 37 | "version" : "12.8.0" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-collections", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-collections.git", 44 | "state" : { 45 | "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", 46 | "version" : "1.2.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-log", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/apple/swift-log.git", 53 | "state" : { 54 | "revision" : "3d8596ed08bd13520157f0355e35caed215ffbfa", 55 | "version" : "1.6.3" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-syntax", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/swiftlang/swift-syntax.git", 62 | "state" : { 63 | "revision" : "0687f71944021d616d34d922343dcef086855920", 64 | "version" : "600.0.1" 65 | } 66 | }, 67 | { 68 | "identity" : "swiftcbor", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/MasterJ93/SwiftCBOR.git", 71 | "state" : { 72 | "revision" : "0b01caa633889f6d64f73f1c01607358d36fe6e6", 73 | "version" : "0.4.0" 74 | } 75 | }, 76 | { 77 | "identity" : "variableblur", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/nikstar/VariableBlur", 80 | "state" : { 81 | "revision" : "c8dc0ada195dfd47354cec5994c2dd434aa6c146", 82 | "version" : "1.2.1" 83 | } 84 | }, 85 | { 86 | "identity" : "viewinspector", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/nalexn/ViewInspector", 89 | "state" : { 90 | "revision" : "788e7879d38a839c4e348ab0762dcc0364e646a2", 91 | "version" : "0.10.1" 92 | } 93 | } 94 | ], 95 | "version" : 3 96 | } 97 | -------------------------------------------------------------------------------- /IcySky.xcodeproj/xcshareddata/xcschemes/IcySky.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 35 | 36 | 38 | 39 | 40 | 41 | 44 | 50 | 51 | 52 | 53 | 54 | 64 | 66 | 72 | 73 | 74 | 75 | 81 | 83 | 89 | 90 | 91 | 92 | 94 | 95 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /Images/glyph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/IcySky/2c340642074caf3d5a8eefaf4786ee5e1d5276be/Images/glyph.png -------------------------------------------------------------------------------- /Images/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/IcySky/2c340642074caf3d5a8eefaf4786ee5e1d5276be/Images/image1.png -------------------------------------------------------------------------------- /Images/image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/IcySky/2c340642074caf3d5a8eefaf4786ee5e1d5276be/Images/image2.png -------------------------------------------------------------------------------- /PackageTests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "A48D26C9-318A-41CA-A52E-8635099A1F4E", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "testTimeoutsEnabled" : true 13 | }, 14 | "testTargets" : [ 15 | { 16 | "target" : { 17 | "containerPath" : "container:", 18 | "identifier" : "DesignSystemTests", 19 | "name" : "DesignSystemTests" 20 | } 21 | }, 22 | { 23 | "target" : { 24 | "containerPath" : "container:", 25 | "identifier" : "AuthTests", 26 | "name" : "AuthTests" 27 | } 28 | }, 29 | { 30 | "target" : { 31 | "containerPath" : "container:", 32 | "identifier" : "FeedUITests", 33 | "name" : "FeedUITests" 34 | } 35 | } 36 | ], 37 | "version" : 1 38 | } 39 | -------------------------------------------------------------------------------- /Packages/Features/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Packages/Features/.swiftpm/xcode/xcshareddata/xcschemes/AuthUI.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Packages/Features/.swiftpm/xcode/xcshareddata/xcschemes/DesignSystem.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Packages/Features/.swiftpm/xcode/xcshareddata/xcschemes/DesignSystemTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 19 | 25 | 26 | 27 | 28 | 29 | 39 | 40 | 46 | 47 | 49 | 50 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Packages/Features/.swiftpm/xcode/xcshareddata/xcschemes/FeedUI.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 35 | 41 | 42 | 43 | 44 | 45 | 55 | 56 | 62 | 63 | 69 | 70 | 71 | 72 | 74 | 75 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /Packages/Features/.swiftpm/xcode/xcshareddata/xcschemes/FeedUITests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 19 | 25 | 26 | 27 | 28 | 29 | 39 | 40 | 46 | 47 | 49 | 50 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Packages/Features/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "4f77c8b91eff918d837995031f0b9be90d3f55546e869084e3864f8fef0f63b2", 3 | "pins" : [ 4 | { 5 | "identity" : "atprotokit", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/MasterJ93/ATProtoKit", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "ca2ea402e498273fce3e181ed767019f51305280" 11 | } 12 | }, 13 | { 14 | "identity" : "keychain-swift", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/evgenyneu/keychain-swift", 17 | "state" : { 18 | "revision" : "5e1b02b6a9dac2a759a1d5dbc175c86bd192a608", 19 | "version" : "24.0.0" 20 | } 21 | }, 22 | { 23 | "identity" : "nuke", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/kean/Nuke", 26 | "state" : { 27 | "revision" : "0ead44350d2737db384908569c012fe67c421e4d", 28 | "version" : "12.8.0" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-collections", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/apple/swift-collections.git", 35 | "state" : { 36 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 37 | "version" : "1.1.4" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-log", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-log.git", 44 | "state" : { 45 | "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", 46 | "version" : "1.6.1" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-syntax", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/swiftlang/swift-syntax.git", 53 | "state" : { 54 | "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", 55 | "version" : "509.1.1" 56 | } 57 | }, 58 | { 59 | "identity" : "swiftcbor", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/MasterJ93/SwiftCBOR.git", 62 | "state" : { 63 | "revision" : "0b01caa633889f6d64f73f1c01607358d36fe6e6", 64 | "version" : "0.4.0" 65 | } 66 | }, 67 | { 68 | "identity" : "variableblur", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/nikstar/VariableBlur", 71 | "state" : { 72 | "revision" : "41698eecb3373550b2100dbcd308d886501d7033", 73 | "version" : "1.2.0" 74 | } 75 | }, 76 | { 77 | "identity" : "viewinspector", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/nalexn/ViewInspector", 80 | "state" : { 81 | "revision" : "5acfa0a3c095ac9ad050abe51c60d1831e8321da", 82 | "version" : "0.10.0" 83 | } 84 | } 85 | ], 86 | "version" : 3 87 | } 88 | -------------------------------------------------------------------------------- /Packages/Features/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let baseDeps: [PackageDescription.Target.Dependency] = [ 7 | .product(name: "Network", package: "Model"), 8 | .product(name: "Models", package: "Model"), 9 | .product(name: "Auth", package: "Model"), 10 | .product(name: "User", package: "Model"), 11 | .product(name: "Destinations", package: "Model"), 12 | "DesignSystem", 13 | ] 14 | 15 | let package = Package( 16 | name: "Features", 17 | platforms: [.iOS(.v18), .macOS(.v15)], 18 | products: [ 19 | .library(name: "FeedUI", targets: ["FeedUI"]), 20 | .library(name: "PostUI", targets: ["PostUI"]), 21 | .library(name: "ProfileUI", targets: ["ProfileUI"]), 22 | .library(name: "AuthUI", targets: ["AuthUI"]), 23 | .library(name: "SettingsUI", targets: ["SettingsUI"]), 24 | .library(name: "NotificationsUI", targets: ["NotificationsUI"]), 25 | .library(name: "DesignSystem", targets: ["DesignSystem"]), 26 | .library(name: "MediaUI", targets: ["MediaUI"]), 27 | ], 28 | dependencies: [ 29 | .package(name: "Model", path: "../Model"), 30 | .package(url: "https://github.com/nikstar/VariableBlur", from: "1.2.1"), 31 | .package(url: "https://github.com/nalexn/ViewInspector", from: "0.10.1"), 32 | .package(url: "https://github.com/kean/Nuke", from: "12.8.0"), 33 | .package(url: "https://github.com/Dimillian/AppRouter.git", from: "1.0.2"), 34 | ], 35 | targets: [ 36 | .target( 37 | name: "FeedUI", 38 | dependencies: baseDeps 39 | ), 40 | .testTarget( 41 | name: "FeedUITests", 42 | dependencies: [ 43 | "FeedUI", 44 | .product(name: "ViewInspector", package: "ViewInspector"), 45 | ] 46 | ), 47 | .target( 48 | name: "PostUI", 49 | dependencies: baseDeps + [ 50 | "FeedUI", 51 | .product(name: "Nuke", package: "Nuke"), 52 | .product(name: "NukeUI", package: "Nuke"), 53 | ] 54 | ), 55 | .target( 56 | name: "ProfileUI", 57 | dependencies: baseDeps + ["FeedUI", "PostUI"] 58 | ), 59 | .target( 60 | name: "AuthUI", 61 | dependencies: baseDeps 62 | ), 63 | .target( 64 | name: "SettingsUI", 65 | dependencies: baseDeps 66 | ), 67 | .target( 68 | name: "MediaUI", 69 | dependencies: baseDeps + [ 70 | .product(name: "Nuke", package: "Nuke"), 71 | .product(name: "NukeUI", package: "Nuke"), 72 | ] 73 | ), 74 | .target( 75 | name: "NotificationsUI", 76 | dependencies: baseDeps + ["PostUI"] 77 | ), 78 | .target( 79 | name: "DesignSystem", 80 | dependencies: [ 81 | .product(name: "VariableBlur", package: "VariableBlur"), 82 | .product(name: "Destinations", package: "Model"), 83 | "AppRouter", 84 | ] 85 | ), 86 | .testTarget( 87 | name: "DesignSystemTests", 88 | dependencies: [ 89 | "DesignSystem", 90 | .product(name: "ViewInspector", package: "ViewInspector"), 91 | ] 92 | ), 93 | ] 94 | ) 95 | -------------------------------------------------------------------------------- /Packages/Features/Sources/AuthUI/AuthView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import Auth 3 | import DesignSystem 4 | import Models 5 | import Network 6 | import SwiftUI 7 | 8 | public struct AuthView: View { 9 | @Environment(Auth.self) private var auth 10 | 11 | @State private var handle: String = "" 12 | @State private var appPassword: String = "" 13 | @State private var error: String? = nil 14 | 15 | public init() {} 16 | 17 | public var body: some View { 18 | Form { 19 | HeaderView(title: "🦋 Bluesky Login") 20 | 21 | Section { 22 | TextField("john@bsky.social", text: $handle) 23 | .font(.title2) 24 | .textInputAutocapitalization(.never) 25 | SecureField("App Password", text: $appPassword) 26 | .font(.title2) 27 | } 28 | .listRowSeparator(.hidden) 29 | 30 | Section { 31 | Button { 32 | Task { 33 | do { 34 | try await auth.authenticate(handle: handle, appPassword: appPassword) 35 | } catch { 36 | self.error = error.localizedDescription 37 | } 38 | } 39 | } label: { 40 | Text("🦋 Login to Bluesky") 41 | .font(.headline) 42 | .padding() 43 | } 44 | .frame(maxWidth: .infinity) 45 | .buttonStyle(.pill) 46 | 47 | if let error { 48 | Text(error) 49 | .foregroundColor(.red) 50 | } 51 | } 52 | } 53 | .scrollContentBackground(.hidden) 54 | } 55 | } 56 | 57 | #Preview { 58 | @Previewable @State var auth: Auth = .init() 59 | ScrollView { 60 | Text("Hello World") 61 | } 62 | .sheet(isPresented: .constant(true)) { 63 | AuthView() 64 | .environment(auth) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Packages/Features/Sources/DesignSystem/Base/Colors.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Color { 4 | public static var shadowPrimary: Color { 5 | Color("shadowPrimary", bundle: .module) 6 | } 7 | 8 | public static var shadowSecondary: Color { 9 | Color("shadowSecondary", bundle: .module) 10 | } 11 | 12 | public static var blueskyBackground: Color { 13 | Color(UIColor(red: 2 / 255, green: 113 / 255, blue: 1, alpha: 1)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Packages/Features/Sources/DesignSystem/Base/Colors.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Packages/Features/Sources/DesignSystem/Base/Colors.xcassets/shadowPrimary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0", 9 | "green" : "0", 10 | "red" : "0" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "255", 27 | "green" : "255", 28 | "red" : "255" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Packages/Features/Sources/DesignSystem/Base/Colors.xcassets/shadowSecondary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "255", 9 | "green" : "255", 10 | "red" : "255" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0", 27 | "green" : "0", 28 | "red" : "0" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Packages/Features/Sources/DesignSystem/Base/Container.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct ScreenContainerModifier: ViewModifier { 4 | public init() {} 5 | 6 | public func body(content: Content) -> some View { 7 | content 8 | .listStyle(.plain) 9 | .navigationBarHidden(true) 10 | .safeAreaPadding(.init(top: 0, leading: 0, bottom: .tabBarHeight, trailing: 0)) 11 | } 12 | } 13 | 14 | extension View { 15 | public func screenContainer() -> some View { 16 | modifier(ScreenContainerModifier()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Packages/Features/Sources/DesignSystem/Base/GlowingRoundedRectangle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct GlowingRoundedRectangle: ViewModifier { 4 | let cornerRadius: CGFloat 5 | 6 | public init(cornerRadius: CGFloat = 8) { 7 | self.cornerRadius = cornerRadius 8 | } 9 | 10 | public func body(content: Content) -> some View { 11 | content.overlay { 12 | RoundedRectangle(cornerRadius: cornerRadius) 13 | .stroke( 14 | LinearGradient( 15 | colors: [.shadowPrimary.opacity(0.5), .indigo.opacity(0.5)], 16 | startPoint: .topLeading, 17 | endPoint: .bottomTrailing), 18 | lineWidth: 1) 19 | } 20 | .clipShape(.rect(cornerRadius: cornerRadius)) 21 | .shadow(color: .indigo.opacity(0.3), radius: 2) 22 | } 23 | } 24 | 25 | extension View { 26 | public func glowingRoundedRectangle(cornerRadius: CGFloat = 8) -> some View { 27 | modifier(GlowingRoundedRectangle(cornerRadius: cornerRadius)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Packages/Features/Sources/DesignSystem/Base/Pill.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct PillModifier: ViewModifier { 4 | @Environment(\.colorScheme) var colorScheme 5 | 6 | let material: Material 7 | let isPressed: Bool 8 | let isCircle: Bool 9 | 10 | public init(material: Material, isPressed: Bool, isCircle: Bool = false) { 11 | self.material = material 12 | self.isPressed = isPressed 13 | self.isCircle = isCircle 14 | } 15 | 16 | public func body(content: Content) -> some View { 17 | content.background { 18 | if isCircle { 19 | background 20 | .clipShape(Circle()) 21 | } 22 | if colorScheme == .dark { 23 | background 24 | .clipShape(Capsule()) 25 | } else { 26 | background 27 | } 28 | } 29 | } 30 | 31 | private var background: some View { 32 | Capsule() 33 | .foregroundStyle( 34 | material 35 | .shadow(.inner(color: .shadowSecondary, radius: isPressed ? 5 : 1, x: 0, y: 1)) 36 | ) 37 | .shadow( 38 | color: .shadowPrimary.opacity(isPressed ? 0 : 0.2), radius: isPressed ? 5 : 2, x: 1, y: 1) 39 | } 40 | } 41 | 42 | extension View { 43 | public func pillStyle(material: Material = .ultraThickMaterial, isPressed: Bool = false) 44 | -> some View 45 | { 46 | modifier(PillModifier(material: material, isPressed: isPressed)) 47 | } 48 | } 49 | 50 | extension View { 51 | public func circleStyle(material: Material = .ultraThickMaterial, isPressed: Bool = false) 52 | -> some View 53 | { 54 | modifier(PillModifier(material: material, isPressed: isPressed, isCircle: true)) 55 | } 56 | } 57 | 58 | #Preview(traits: .sizeThatFitsLayout) { 59 | Button( 60 | action: { 61 | 62 | }, 63 | label: { 64 | Text("Hello world") 65 | .padding() 66 | } 67 | ) 68 | .buttonStyle(.pill) 69 | .padding() 70 | .environment(\.colorScheme, .light) 71 | .background(.white) 72 | 73 | Button( 74 | action: { 75 | 76 | }, 77 | label: { 78 | Text("Hello world") 79 | .padding() 80 | } 81 | ) 82 | .buttonStyle(.pill) 83 | .padding() 84 | .environment(\.colorScheme, .dark) 85 | .background(.black) 86 | } 87 | -------------------------------------------------------------------------------- /Packages/Features/Sources/DesignSystem/Base/PillButtonStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct PillButtonStyle: ButtonStyle { 4 | public init() {} 5 | 6 | public func makeBody(configuration: Configuration) -> some View { 7 | configuration.label 8 | .pillStyle(isPressed: configuration.isPressed) 9 | } 10 | } 11 | 12 | extension ButtonStyle where Self == PillButtonStyle { 13 | public static var pill: Self { 14 | PillButtonStyle() 15 | } 16 | } 17 | 18 | public struct CircleButtonStyle: ButtonStyle { 19 | public init() {} 20 | 21 | public func makeBody(configuration: Configuration) -> some View { 22 | configuration.label 23 | .circleStyle(isPressed: configuration.isPressed) 24 | } 25 | } 26 | 27 | extension ButtonStyle where Self == CircleButtonStyle { 28 | public static var circle: Self { 29 | CircleButtonStyle() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Packages/Features/Sources/DesignSystem/Components/CustomSpacingLabel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct CustomSpacingLabel: LabelStyle { 4 | let spacing: Double 5 | 6 | public init(spacing: Double = 0.0) { 7 | self.spacing = spacing 8 | } 9 | 10 | public func makeBody(configuration: Configuration) -> some View { 11 | HStack(spacing: spacing) { 12 | configuration.icon 13 | configuration.title 14 | } 15 | } 16 | } 17 | 18 | extension LabelStyle where Self == CustomSpacingLabel { 19 | public static func customSpacing(_ spacing: Double) -> Self { 20 | CustomSpacingLabel(spacing: spacing) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Packages/Features/Sources/DesignSystem/Components/TabBarView.swift: -------------------------------------------------------------------------------- 1 | import AppRouter 2 | import Destinations 3 | import SwiftUI 4 | 5 | extension CGFloat { 6 | public static let tabBarHeight: CGFloat = 70 7 | } 8 | 9 | public struct TabBarView: View { 10 | @Environment(AppRouter.self) var router 11 | 12 | public init() {} 13 | 14 | public var body: some View { 15 | ZStack(alignment: .center) { 16 | backButtonView 17 | tabbarView 18 | } 19 | } 20 | 21 | private var backButtonView: some View { 22 | Button { 23 | router[router.selectedTab].removeLast() 24 | } label: { 25 | Image(systemName: "chevron.left") 26 | .symbolRenderingMode(.palette) 27 | .foregroundStyle(.primary) 28 | .imageScale(.medium) 29 | .foregroundStyle( 30 | .linearGradient( 31 | colors: [.indigo, .secondary], 32 | startPoint: .top, endPoint: .bottom) 33 | ) 34 | .shadow(color: .clear, radius: 1, x: 0, y: 0) 35 | .frame(width: 50, height: 50) 36 | } 37 | .buttonStyle(.circle) 38 | .animation(.bouncy, value: router.selectedTabPath) 39 | .offset(x: router.selectedTabPath.isEmpty ? 0 : -164) 40 | } 41 | 42 | private var tabbarView: some View { 43 | HStack(spacing: 32) { 44 | ForEach(AppTab.allCases, id: \.rawValue) { tab in 45 | Button { 46 | withAnimation { 47 | if router.selectedTab == tab { 48 | router.popToRoot(for: tab) 49 | } 50 | router.selectedTab = tab 51 | } 52 | } label: { 53 | Image(systemName: tab.icon) 54 | .symbolRenderingMode(.palette) 55 | .symbolVariant(router.selectedTab == tab ? .fill : .none) 56 | .symbolEffect( 57 | .bounce, 58 | options: .repeat(router.selectedTab == tab ? 1 : 0), 59 | value: router.selectedTab 60 | ) 61 | .imageScale(.medium) 62 | .foregroundStyle( 63 | .linearGradient( 64 | colors: router.selectedTab == tab ? [.indigo, .purple] : [.indigo, .secondary], 65 | startPoint: .top, endPoint: .bottom) 66 | ) 67 | .shadow(color: router.selectedTab == tab ? .indigo : .clear, radius: 1, x: 0, y: 0) 68 | } 69 | } 70 | } 71 | .padding() 72 | .pillStyle(material: .regular) 73 | } 74 | } 75 | 76 | #Preview(traits: .sizeThatFitsLayout) { 77 | TabBarView() 78 | .padding() 79 | .environment(\.colorScheme, .light) 80 | .environment(AppRouter(initialTab: .feed)) 81 | 82 | TabBarView() 83 | .padding() 84 | .background(.black) 85 | .environment(\.colorScheme, .dark) 86 | .environment(AppRouter(initialTab: .feed)) 87 | } 88 | -------------------------------------------------------------------------------- /Packages/Features/Sources/DesignSystem/Header/HeaderVIew.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct HeaderView: View { 4 | @Environment(\.dismiss) var dismiss 5 | let title: String 6 | let subtitle: String? 7 | let showBack: Bool 8 | 9 | public init( 10 | title: String, 11 | subtitle: String? = nil, 12 | showBack: Bool = true 13 | ) { 14 | self.title = title 15 | self.subtitle = subtitle 16 | self.showBack = showBack 17 | } 18 | 19 | public var body: some View { 20 | HStack { 21 | if showBack { 22 | Image(systemName: "chevron.backward") 23 | .id("back") 24 | } 25 | VStack(alignment: .leading, spacing: 4) { 26 | Text(title) 27 | .lineLimit(1) 28 | .minimumScaleFactor(0.5) 29 | if let subtitle { 30 | Text(subtitle) 31 | .foregroundStyle(.secondary) 32 | .font(.callout) 33 | .lineLimit(1) 34 | .minimumScaleFactor(0.5) 35 | } 36 | } 37 | } 38 | .onTapGesture { 39 | dismiss() 40 | } 41 | .foregroundStyle( 42 | .primary.shadow( 43 | .inner( 44 | color: .shadowSecondary.opacity(0.5), 45 | radius: 1, x: -1, y: -1)) 46 | ) 47 | .shadow(color: .black.opacity(0.2), radius: 1, x: 1, y: 1) 48 | .font(.title) 49 | .fontWeight(.bold) 50 | .listRowSeparator(.hidden) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Packages/Features/Sources/FeedUI/List/FeedsListDividerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct FeedsListDividerView: View { 4 | var body: some View { 5 | HStack { 6 | Rectangle() 7 | .fill( 8 | LinearGradient( 9 | colors: [.indigo, .purple], 10 | startPoint: .leading, 11 | endPoint: .trailing) 12 | ) 13 | .frame(height: 1) 14 | .frame(maxWidth: .infinity) 15 | } 16 | .listRowSeparator(.hidden) 17 | .listRowInsets(.init()) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Packages/Features/Sources/FeedUI/List/FeedsListErrorView.swift: -------------------------------------------------------------------------------- 1 | import DesignSystem 2 | import SwiftUI 3 | 4 | struct FeedsListErrorView: View { 5 | let error: Error 6 | let retry: () async -> Void 7 | 8 | var body: some View { 9 | VStack { 10 | Text("Error: \(error.localizedDescription)") 11 | .foregroundColor(.red) 12 | Button { 13 | Task { 14 | await retry() 15 | } 16 | } label: { 17 | Text("Retry") 18 | .padding() 19 | } 20 | .buttonStyle(.pill) 21 | } 22 | .listRowSeparator(.hidden) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Packages/Features/Sources/FeedUI/List/FeedsListFilter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum FeedsListFilter: String, CaseIterable, Identifiable { 4 | case suggested = "Suggested" 5 | case myFeeds = "My Feeds" 6 | 7 | public var id: Self { self } 8 | 9 | var icon: String { 10 | switch self { 11 | case .suggested: return "sparkles.rectangle.stack" 12 | case .myFeeds: return "person.crop.rectangle.stack" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Packages/Features/Sources/FeedUI/List/FeedsListRecentSection.swift: -------------------------------------------------------------------------------- 1 | import Models 2 | import SwiftData 3 | import SwiftUI 4 | 5 | struct FeedsListRecentSection: View { 6 | @Environment(\.modelContext) var modelContext 7 | 8 | @Binding var isRecentFeedExpanded: Bool 9 | 10 | @Query(recentFeedItemsDescriptor) var recentFeedItems: [RecentFeedItem] 11 | 12 | var body: some View { 13 | HStack { 14 | Image(systemName: "chevron.right") 15 | .rotationEffect(.degrees(isRecentFeedExpanded ? 90 : 0)) 16 | Text("Recently Viewed") 17 | } 18 | .font(.subheadline) 19 | .fontWeight(.semibold) 20 | .foregroundStyle(.secondary) 21 | .listRowSeparator(.hidden) 22 | .onTapGesture { 23 | withAnimation { 24 | isRecentFeedExpanded.toggle() 25 | } 26 | } 27 | 28 | if isRecentFeedExpanded { 29 | Section { 30 | TimelineFeedRowView() 31 | ForEach(recentFeedItems) { item in 32 | RecentlyViewedFeedRowView(item: item) 33 | } 34 | .onDelete { indexSet in 35 | for index in indexSet { 36 | modelContext.delete(recentFeedItems[index]) 37 | } 38 | } 39 | FeedsListDividerView() 40 | } 41 | } 42 | } 43 | } 44 | 45 | // MARK: - SwiftData 46 | extension FeedsListRecentSection { 47 | static var recentFeedItemsDescriptor: FetchDescriptor { 48 | var descriptor = FetchDescriptor(sortBy: [ 49 | SortDescriptor(\.lastViewedAt, order: .reverse) 50 | ] 51 | ) 52 | descriptor.fetchLimit = 4 53 | return descriptor 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Packages/Features/Sources/FeedUI/List/FeedsListSearchField.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct FeedsListSearchField: View { 4 | 5 | @Binding var searchText: String 6 | @Binding var isInSearch: Bool 7 | var isSearchFocused: FocusState.Binding 8 | 9 | public init( 10 | searchText: Binding, 11 | isInSearch: Binding, 12 | isSearchFocused: FocusState.Binding 13 | ) { 14 | _searchText = searchText 15 | _isInSearch = isInSearch 16 | self.isSearchFocused = isSearchFocused 17 | } 18 | 19 | public var body: some View { 20 | HStack { 21 | HStack { 22 | Image(systemName: "magnifyingglass") 23 | TextField("Search", text: $searchText) 24 | .focused(isSearchFocused) 25 | .allowsHitTesting(isInSearch) 26 | } 27 | .frame(maxWidth: isInSearch ? .infinity : 100) 28 | .padding() 29 | .pillStyle() 30 | if isInSearch { 31 | Button { 32 | withAnimation { 33 | isInSearch.toggle() 34 | isSearchFocused.wrappedValue = false 35 | searchText = "" 36 | } 37 | } label: { 38 | Image(systemName: "xmark") 39 | .frame(width: 50, height: 50) 40 | } 41 | .buttonStyle(.circle) 42 | .transition(.push(from: .leading).combined(with: .scale).combined(with: .opacity)) 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Packages/Features/Sources/FeedUI/List/FeedsListTitleView.swift: -------------------------------------------------------------------------------- 1 | import Network 2 | import SwiftUI 3 | 4 | public struct FeedsListTitleView: View { 5 | @Binding var filter: FeedsListFilter 6 | @Binding var searchText: String 7 | @Binding var isInSearch: Bool 8 | var isSearchFocused: FocusState.Binding 9 | 10 | public init( 11 | filter: Binding, 12 | searchText: Binding, 13 | isInSearch: Binding, 14 | isSearchFocused: FocusState.Binding 15 | ) { 16 | self._filter = filter 17 | self._searchText = searchText 18 | self._isInSearch = isInSearch 19 | self.isSearchFocused = isSearchFocused 20 | } 21 | 22 | public var body: some View { 23 | HStack(alignment: .center) { 24 | Menu { 25 | ForEach(FeedsListFilter.allCases) { filter in 26 | Button(action: { 27 | self.filter = filter 28 | }) { 29 | Label(filter.rawValue, systemImage: filter.icon) 30 | } 31 | } 32 | } label: { 33 | HStack { 34 | VStack(alignment: .leading, spacing: 2) { 35 | Text("Feeds") 36 | .foregroundStyle( 37 | .primary.shadow( 38 | .inner( 39 | color: .shadowSecondary.opacity(0.5), 40 | radius: 1, x: -1, y: -1)) 41 | ) 42 | .shadow(color: .black.opacity(0.2), radius: 1, x: 1, y: 1) 43 | .font(.title) 44 | .fontWeight(.bold) 45 | Text(filter.rawValue) 46 | .font(.subheadline) 47 | .foregroundStyle(.secondary) 48 | } 49 | VStack(spacing: 6) { 50 | Image(systemName: "chevron.up") 51 | Image(systemName: "chevron.down") 52 | } 53 | .imageScale(.large) 54 | .offset(y: 2) 55 | } 56 | } 57 | .buttonStyle(.plain) 58 | .offset(x: isInSearch ? -200 : 0) 59 | .opacity(isInSearch ? 0 : 1) 60 | 61 | Spacer() 62 | 63 | FeedsListSearchField( 64 | searchText: $searchText, 65 | isInSearch: $isInSearch, 66 | isSearchFocused: isSearchFocused 67 | ) 68 | .padding(.leading, isInSearch ? -120 : 0) 69 | .onTapGesture { 70 | withAnimation { 71 | isInSearch.toggle() 72 | isSearchFocused.wrappedValue = true 73 | } 74 | } 75 | .transition(.slide) 76 | } 77 | .animation(.smooth, value: isInSearch) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Packages/Features/Sources/FeedUI/List/FeedsListView.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Models 5 | import Network 6 | import SwiftUI 7 | import User 8 | import VariableBlur 9 | 10 | public struct FeedsListView: View { 11 | @Environment(BSkyClient.self) var client 12 | @Environment(CurrentUser.self) var currentUser 13 | 14 | @State var feeds: [FeedItem] = [] 15 | @State var filter: FeedsListFilter = .suggested 16 | 17 | @State var isRecentFeedExpanded: Bool = true 18 | 19 | @State var isInSearch: Bool = false 20 | @State var searchText: String = "" 21 | 22 | @State var error: Error? 23 | 24 | @FocusState var isSearchFocused: Bool 25 | 26 | public init() {} 27 | 28 | public var body: some View { 29 | List { 30 | headerView 31 | if let error { 32 | FeedsListErrorView(error: error) { 33 | await fetchSuggestedFeed() 34 | } 35 | } 36 | if !isInSearch { 37 | FeedsListRecentSection(isRecentFeedExpanded: $isRecentFeedExpanded) 38 | } 39 | feedsSection 40 | } 41 | .screenContainer() 42 | .scrollDismissesKeyboard(.immediately) 43 | .task(id: filter) { 44 | guard !isInSearch else { return } 45 | switch filter { 46 | case .suggested: 47 | await fetchSuggestedFeed() 48 | case .myFeeds: 49 | await fetchMyFeeds() 50 | } 51 | } 52 | } 53 | 54 | private var headerView: some View { 55 | FeedsListTitleView( 56 | filter: $filter, 57 | searchText: $searchText, 58 | isInSearch: $isInSearch, 59 | isSearchFocused: $isSearchFocused 60 | ) 61 | .task(id: searchText) { 62 | guard !searchText.isEmpty else { return } 63 | await searchFeed(query: searchText) 64 | } 65 | .onChange(of: isInSearch, initial: false) { 66 | guard !isInSearch else { return } 67 | Task { await fetchSuggestedFeed() } 68 | } 69 | .onChange(of: currentUser.savedFeeds.count) { 70 | switch filter { 71 | case .suggested: 72 | feeds = feeds.filter { feed in 73 | !currentUser.savedFeeds.contains { $0.value == feed.uri } 74 | } 75 | case .myFeeds: 76 | Task { await fetchMyFeeds() } 77 | } 78 | } 79 | .listRowSeparator(.hidden) 80 | } 81 | 82 | private var feedsSection: some View { 83 | Section { 84 | ForEach(feeds) { feed in 85 | FeedRowView(feed: feed) 86 | } 87 | } 88 | } 89 | } 90 | 91 | // MARK: - Network 92 | extension FeedsListView { 93 | private func fetchSuggestedFeed() async { 94 | error = nil 95 | do { 96 | let feeds = try await client.protoClient.getPopularFeedGenerators(matching: nil) 97 | withAnimation { 98 | self.feeds = feeds.feeds.map { $0.feedItem }.filter { feed in 99 | !currentUser.savedFeeds.contains { $0.value == feed.uri } 100 | } 101 | } 102 | } catch { 103 | self.error = error 104 | } 105 | } 106 | 107 | private func fetchMyFeeds() async { 108 | do { 109 | let feeds = try await client.protoClient.getFeedGenerators( 110 | by: currentUser.savedFeeds.map { $0.value }) 111 | withAnimation { 112 | self.feeds = feeds.feeds.map { $0.feedItem } 113 | } 114 | } catch { 115 | print(error) 116 | } 117 | } 118 | 119 | private func searchFeed(query: String) async { 120 | do { 121 | try await Task.sleep(for: .milliseconds(250)) 122 | let feeds = try await client.protoClient.getPopularFeedGenerators(matching: query) 123 | withAnimation { 124 | self.feeds = feeds.feeds.map { $0.feedItem } 125 | } 126 | } catch { 127 | print(error) 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /Packages/Features/Sources/FeedUI/Row/FeedCompactRowView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Destinations 5 | import Models 6 | import Network 7 | import SwiftUI 8 | 9 | struct FeedCompactRowView: View { 10 | let title: String 11 | let image: String 12 | let destination: RouterDestination 13 | 14 | var body: some View { 15 | NavigationLink(value: destination) { 16 | HStack { 17 | Image(systemName: image) 18 | .imageScale(.medium) 19 | .foregroundStyle(.white) 20 | .frame(width: 32, height: 32) 21 | .background(RoundedRectangle(cornerRadius: 8).fill(Color.blueskyBackground)) 22 | .clipShape(RoundedRectangle(cornerRadius: 8)) 23 | .shadow(color: .shadowPrimary.opacity(0.7), radius: 2) 24 | VStack(alignment: .leading) { 25 | Text(title) 26 | .font(.title3) 27 | .fontWeight(.bold) 28 | .foregroundStyle( 29 | .primary.shadow( 30 | .inner( 31 | color: .shadowSecondary.opacity(0.5), 32 | radius: 2, x: -1, y: -1))) 33 | } 34 | } 35 | .padding(.vertical, 12) 36 | } 37 | .listRowSeparator(.hidden) 38 | } 39 | } 40 | 41 | #Preview { 42 | NavigationStack { 43 | List { 44 | FeedCompactRowView( 45 | title: "Following", 46 | image: "clock", 47 | destination: .timeline 48 | ) 49 | } 50 | .listStyle(.plain) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Packages/Features/Sources/FeedUI/Row/FeedRowView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Destinations 5 | import Models 6 | import Network 7 | import SwiftUI 8 | 9 | struct FeedRowView: View { 10 | let feed: FeedItem 11 | 12 | var body: some View { 13 | NavigationLink(value: RouterDestination.feed(feed)) { 14 | VStack(alignment: .leading, spacing: 12) { 15 | headerView 16 | if let description = feed.description { 17 | Text(description) 18 | .font(.callout) 19 | .foregroundStyle(.secondary) 20 | } 21 | Text("By @\(feed.creatorHandle)") 22 | .font(.callout) 23 | .foregroundStyle(.tertiary) 24 | } 25 | .padding(.vertical, 12) 26 | } 27 | .listRowSeparator(.hidden) 28 | } 29 | 30 | @ViewBuilder 31 | var headerView: some View { 32 | HStack { 33 | AsyncImage(url: feed.avatarImageURL) { phase in 34 | switch phase { 35 | case .success(let image): 36 | image 37 | .resizable() 38 | .scaledToFit() 39 | .frame(width: 44, height: 44) 40 | .clipShape(RoundedRectangle(cornerRadius: 8)) 41 | .shadow(color: .shadowPrimary.opacity(0.7), radius: 2) 42 | default: 43 | Image(systemName: "antenna.radiowaves.left.and.right") 44 | .imageScale(.medium) 45 | .foregroundStyle(.white) 46 | .frame(width: 44, height: 44) 47 | .background(RoundedRectangle(cornerRadius: 8).fill(Color.blueskyBackground)) 48 | .clipShape(RoundedRectangle(cornerRadius: 8)) 49 | .shadow(color: .shadowPrimary.opacity(0.7), radius: 2) 50 | } 51 | } 52 | VStack(alignment: .leading) { 53 | Text(feed.displayName) 54 | .font(.title2) 55 | .fontWeight(.bold) 56 | .foregroundStyle( 57 | .primary.shadow( 58 | .inner( 59 | color: .shadowSecondary.opacity(0.5), 60 | radius: 2, x: -1, y: -1))) 61 | likeView 62 | } 63 | } 64 | } 65 | 66 | @ViewBuilder 67 | var likeView: some View { 68 | HStack(spacing: 2) { 69 | Image(systemName: feed.liked ? "heart.fill" : "heart") 70 | .foregroundStyle( 71 | LinearGradient( 72 | colors: [.indigo.opacity(0.4), .red], 73 | startPoint: .top, 74 | endPoint: .bottom 75 | ) 76 | .shadow(.inner(color: .red, radius: 3)) 77 | ) 78 | .shadow(color: .red, radius: 1) 79 | Text("\(feed.likesCount) likes") 80 | .font(.callout) 81 | .foregroundStyle(.secondary) 82 | 83 | } 84 | } 85 | } 86 | 87 | #Preview { 88 | NavigationStack { 89 | List { 90 | FeedRowView( 91 | feed: FeedItem( 92 | uri: "", 93 | displayName: "Preview Feed", 94 | description: "This is a sample feed", 95 | avatarImageURL: nil, 96 | creatorHandle: "dimillian.app", 97 | likesCount: 50, 98 | liked: false 99 | ) 100 | ) 101 | FeedRowView( 102 | feed: FeedItem( 103 | uri: "", 104 | displayName: "Preview Feed", 105 | description: "This is a sample feed", 106 | avatarImageURL: nil, 107 | creatorHandle: "dimillian.app", 108 | likesCount: 50, 109 | liked: true 110 | ) 111 | ) 112 | } 113 | .listStyle(.plain) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Packages/Features/Sources/FeedUI/Row/RecentlyViewedFeedRowView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Destinations 5 | import Models 6 | import Network 7 | import SwiftUI 8 | 9 | public struct RecentlyViewedFeedRowView: View { 10 | public let item: RecentFeedItem 11 | 12 | public init(item: RecentFeedItem) { 13 | self.item = item 14 | } 15 | 16 | public var body: some View { 17 | NavigationLink( 18 | value: RouterDestination.feed( 19 | FeedItem( 20 | uri: item.uri, 21 | displayName: item.name, 22 | description: nil, 23 | avatarImageURL: item.avatarImageURL, 24 | creatorHandle: "", 25 | likesCount: 0, 26 | liked: false 27 | )) 28 | ) { 29 | HStack { 30 | AsyncImage(url: item.avatarImageURL) { phase in 31 | switch phase { 32 | case .success(let image): 33 | image 34 | .resizable() 35 | .scaledToFit() 36 | .frame(width: 32, height: 32) 37 | .clipShape(RoundedRectangle(cornerRadius: 8)) 38 | .shadow(color: .shadowPrimary.opacity(0.7), radius: 2) 39 | default: 40 | Image(systemName: "antenna.radiowaves.left.and.right") 41 | .imageScale(.medium) 42 | .foregroundStyle(.white) 43 | .frame(width: 32, height: 32) 44 | .background(RoundedRectangle(cornerRadius: 8).fill(Color.blueskyBackground)) 45 | .clipShape(RoundedRectangle(cornerRadius: 8)) 46 | .shadow(color: .shadowPrimary.opacity(0.7), radius: 2) 47 | } 48 | } 49 | Text(item.name) 50 | .font(.title3) 51 | .fontWeight(.bold) 52 | .foregroundStyle( 53 | .primary.shadow( 54 | .inner( 55 | color: .shadowSecondary.opacity(0.5), 56 | radius: 2, x: -1, y: -1))) 57 | } 58 | } 59 | .listRowSeparator(.hidden) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Packages/Features/Sources/FeedUI/Row/TimelineFeedRowView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Destinations 5 | import Models 6 | import Network 7 | import SwiftUI 8 | 9 | struct TimelineFeedRowView: View { 10 | var body: some View { 11 | NavigationLink(value: RouterDestination.timeline) { 12 | HStack { 13 | Image(systemName: "clock") 14 | .imageScale(.medium) 15 | .foregroundStyle(.white) 16 | .frame(width: 32, height: 32) 17 | .background(RoundedRectangle(cornerRadius: 8).fill(Color.blueskyBackground)) 18 | .clipShape(RoundedRectangle(cornerRadius: 8)) 19 | .shadow(color: .shadowPrimary.opacity(0.7), radius: 2) 20 | Text("Following") 21 | .font(.title3) 22 | .fontWeight(.bold) 23 | .foregroundStyle( 24 | .primary.shadow( 25 | .inner( 26 | color: .shadowSecondary.opacity(0.5), 27 | radius: 2, x: -1, y: -1))) 28 | } 29 | } 30 | .listRowSeparator(.hidden) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Packages/Features/Sources/MediaUI/FullScreenMediaView.swift: -------------------------------------------------------------------------------- 1 | import DesignSystem 2 | import Foundation 3 | import Models 4 | import Nuke 5 | import NukeUI 6 | import SwiftUI 7 | 8 | public struct FullScreenMediaView: View { 9 | @Environment(\.dismiss) private var dismiss 10 | 11 | let images: [Media] 12 | let preloadedImage: URL? 13 | let namespace: Namespace.ID 14 | 15 | @State private var isFirstImageLoaded: Bool = false 16 | @State private var isOverlayVisible: Bool = false 17 | @State private var isSaved: Bool = false 18 | @State private var scrollPosition: Media? 19 | @State private var isAltVisible: Bool = false 20 | 21 | @GestureState private var zoom = 1.0 22 | 23 | public init(images: [Media], preloadedImage: URL?, namespace: Namespace.ID) { 24 | self.images = images 25 | self.preloadedImage = preloadedImage 26 | self.namespace = namespace 27 | } 28 | 29 | var firstImageURL: URL? { 30 | if let preloadedImage, !isFirstImageLoaded { 31 | return preloadedImage 32 | } 33 | return images.first?.url 34 | } 35 | 36 | public var body: some View { 37 | ScrollView(.horizontal, showsIndicators: false) { 38 | LazyHStack { 39 | ForEach(images.indices, id: \.self) { index in 40 | LazyImage( 41 | request: .init( 42 | url: index == 0 ? firstImageURL : images[index].url, 43 | priority: .veryHigh) 44 | ) { state in 45 | if let image = state.image { 46 | image 47 | .resizable() 48 | .scaledToFill() 49 | .aspectRatio(contentMode: .fit) 50 | .scaleEffect(zoom) 51 | .gesture( 52 | MagnifyGesture() 53 | .updating($zoom) { value, gestureState, transaction in 54 | gestureState = value.magnification 55 | } 56 | ) 57 | } else { 58 | RoundedRectangle(cornerRadius: 8) 59 | .fill(.thinMaterial) 60 | } 61 | } 62 | .containerRelativeFrame([.horizontal, .vertical]) 63 | .id(images[index]) 64 | } 65 | } 66 | .scrollTargetLayout() 67 | } 68 | .scrollPosition(id: $scrollPosition) 69 | .overlay(alignment: .topTrailing) { 70 | topActionsView 71 | } 72 | .overlay(alignment: .bottom) { 73 | bottomActionsView 74 | } 75 | .scrollContentBackground(.hidden) 76 | .scrollTargetBehavior(.viewAligned) 77 | .navigationTransition(.zoom(sourceID: images[0].id, in: namespace)) 78 | .containerBackground(.clear, for: .navigation) 79 | .background(.clear) 80 | .toolbarBackground(.clear, for: .navigationBar) 81 | .onTapGesture { 82 | withAnimation { 83 | isOverlayVisible.toggle() 84 | } 85 | } 86 | .task { 87 | scrollPosition = images.first 88 | do { 89 | let data = try await ImagePipeline.shared.data(for: .init(url: images.first?.url)) 90 | if !data.0.isEmpty { 91 | self.isFirstImageLoaded = true 92 | } 93 | } catch {} 94 | } 95 | } 96 | 97 | private var topActionsView: some View { 98 | HStack { 99 | if isOverlayVisible { 100 | Button { 101 | dismiss() 102 | } label: { 103 | Image(systemName: "xmark") 104 | .padding() 105 | } 106 | .buttonStyle(.circle) 107 | .foregroundColor(.indigo) 108 | .padding(.trailing, 16) 109 | .transition(.move(edge: .top).combined(with: .opacity)) 110 | } 111 | } 112 | } 113 | 114 | private var bottomActionsView: some View { 115 | HStack(spacing: 16) { 116 | if isOverlayVisible { 117 | saveButton 118 | shareButton 119 | } 120 | } 121 | } 122 | 123 | private var saveButton: some View { 124 | Button { 125 | Task { 126 | do { 127 | guard let imageURL = scrollPosition?.url else { return } 128 | let data = try await ImagePipeline.shared.data(for: .init(url: imageURL)) 129 | if !data.0.isEmpty, let image = UIImage(data: data.0) { 130 | UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) 131 | withAnimation { 132 | isSaved = true 133 | } 134 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { 135 | withAnimation { 136 | isSaved = false 137 | } 138 | } 139 | } 140 | } catch {} 141 | } 142 | } label: { 143 | if isSaved { 144 | Label("Saved", systemImage: "checkmark") 145 | .padding() 146 | } else { 147 | Label("Save", systemImage: "square.and.arrow.down") 148 | .padding() 149 | } 150 | } 151 | .foregroundColor(.indigo) 152 | .buttonStyle(.pill) 153 | .transition(.move(edge: .bottom).combined(with: .opacity)) 154 | } 155 | 156 | @ViewBuilder 157 | private var shareButton: some View { 158 | if let imageURL = scrollPosition?.url { 159 | ShareLink(item: imageURL) { 160 | Label("Share", systemImage: "square.and.arrow.up") 161 | .padding() 162 | } 163 | .foregroundColor(.indigo) 164 | .buttonStyle(.pill) 165 | .transition(.move(edge: .bottom).combined(with: .opacity)) 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /Packages/Features/Sources/NotificationsUI/NotificationsGroup.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import Foundation 3 | import Models 4 | import Network 5 | import SwiftUI 6 | 7 | @MainActor 8 | struct NotificationsGroup: Identifiable { 9 | let id: String 10 | let timestamp: Date 11 | let type: AppBskyLexicon.Notification.Notification.Reason 12 | let notifications: [AppBskyLexicon.Notification.Notification] 13 | let postItem: PostItem? 14 | 15 | static func groupNotifications( 16 | client: BSkyClient, 17 | _ notifications: [AppBskyLexicon.Notification.Notification] 18 | ) async -> [NotificationsGroup] { 19 | var groups: [NotificationsGroup] = [] 20 | var groupedNotifications: 21 | [AppBskyLexicon.Notification.Notification.Reason: [String: [AppBskyLexicon.Notification 22 | .Notification]]] = [:] 23 | 24 | // Sort notifications by date 25 | let sortedNotifications = notifications.sorted { $0.indexedAt > $1.indexedAt } 26 | 27 | let postsURIs = 28 | Array( 29 | Set( 30 | sortedNotifications 31 | .filter { $0.reason != .follow } 32 | .compactMap { $0.postURI } 33 | )) 34 | var postItems: [PostItem] = [] 35 | do { 36 | postItems = try await client.protoClient.getPosts(postsURIs).posts.map { $0.postItem } 37 | } catch { 38 | postItems = [] 39 | } 40 | 41 | for notification in sortedNotifications { 42 | let reason = notification.reason 43 | 44 | if reason.shouldGroup { 45 | // Group notifications by type and subject 46 | let key = notification.reasonSubjectURI ?? "general" 47 | groupedNotifications[reason, default: [:]][key, default: []].append(notification) 48 | } else { 49 | // Create individual groups for non-grouped notifications 50 | groups.append( 51 | NotificationsGroup( 52 | id: notification.uri, 53 | timestamp: notification.indexedAt, 54 | type: reason, 55 | notifications: [notification], 56 | postItem: postItems.first(where: { $0.uri == notification.postURI }) 57 | )) 58 | } 59 | } 60 | 61 | // Add grouped notifications 62 | for (reason, subjectGroups) in groupedNotifications { 63 | for (subjectURI, notifications) in subjectGroups { 64 | groups.append( 65 | NotificationsGroup( 66 | id: "\(reason)-\(subjectURI)-\(notifications[0].indexedAt.timeIntervalSince1970)", 67 | timestamp: notifications[0].indexedAt, 68 | type: reason, 69 | notifications: notifications, 70 | postItem: postItems.first(where: { $0.uri == notifications[0].postURI }) 71 | )) 72 | } 73 | } 74 | 75 | // Sort all groups by timestamp 76 | return groups.sorted { $0.timestamp > $1.timestamp } 77 | } 78 | } 79 | 80 | extension AppBskyLexicon.Notification.Notification.Reason: @retroactive Hashable, @retroactive 81 | Equatable 82 | { 83 | public func hash(into hasher: inout Hasher) { 84 | hasher.combine(self.rawValue) 85 | } 86 | 87 | public static func == ( 88 | lhs: AppBskyLexicon.Notification.Notification.Reason, 89 | rhs: AppBskyLexicon.Notification.Notification.Reason 90 | ) -> Bool { 91 | lhs.rawValue == rhs.rawValue 92 | } 93 | } 94 | 95 | extension AppBskyLexicon.Notification.Notification { 96 | fileprivate var postURI: String? { 97 | switch reason { 98 | case .follow, .starterpackjoined: return nil 99 | case .like, .repost: return reasonSubjectURI 100 | case .reply, .mention, .quote: return uri 101 | default: return nil 102 | } 103 | } 104 | } 105 | 106 | extension AppBskyLexicon.Notification.Notification.Reason { 107 | fileprivate var shouldGroup: Bool { 108 | switch self { 109 | case .like, .follow, .repost: 110 | return true 111 | case .reply, .mention, .quote, .starterpackjoined: 112 | return false 113 | default: 114 | return false 115 | } 116 | } 117 | 118 | var iconName: String { 119 | switch self { 120 | case .like: return "heart.fill" 121 | case .follow: return "person.fill.badge.plus" 122 | case .repost: return "quote.opening" 123 | case .mention: return "at" 124 | case .quote: return "quote.opening" 125 | case .reply: return "arrowshape.turn.up.left.fill" 126 | case .starterpackjoined: return "star" 127 | default: return "bell.fill" // Fallback for unknown reasons 128 | } 129 | } 130 | 131 | var color: Color { 132 | switch self { 133 | case .like: return .pink 134 | case .follow: return .blue 135 | case .repost: return .green 136 | case .mention: return .purple 137 | case .quote: return .orange 138 | case .reply: return .teal 139 | case .starterpackjoined: return .yellow 140 | default: return .gray // Fallback for unknown reasons 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Packages/Features/Sources/NotificationsUI/NotificationsListView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Models 5 | import Network 6 | import SwiftUI 7 | 8 | public struct NotificationsListView: View { 9 | @Environment(BSkyClient.self) private var client 10 | 11 | @State private var notificationsGroups: [NotificationsGroup] = [] 12 | @State private var cursor: String? 13 | 14 | public init() {} 15 | 16 | public var body: some View { 17 | List { 18 | HeaderView(title: "Notifications", showBack: false) 19 | .padding(.bottom) 20 | 21 | ForEach(notificationsGroups) { group in 22 | Section { 23 | switch group.type { 24 | case .reply: 25 | SingleNotificationRow( 26 | notification: group.notifications[0], 27 | postItem: group.postItem, 28 | actionText: "replied to your post" 29 | ) 30 | case .follow: 31 | GroupedNotificationRow(group: group) { count in 32 | count == 1 ? " followed you" : " and \(count - 1) others followed you" 33 | } 34 | case .like: 35 | GroupedNotificationRow(group: group) { count in 36 | count == 1 ? " liked your post" : " and \(count - 1) others liked your post" 37 | } 38 | case .repost: 39 | GroupedNotificationRow(group: group) { count in 40 | count == 1 ? " reposted your post" : " and \(count - 1) others reposted your post" 41 | } 42 | case .mention: 43 | SingleNotificationRow( 44 | notification: group.notifications[0], 45 | postItem: group.postItem, 46 | actionText: "mentioned you" 47 | ) 48 | case .quote: 49 | SingleNotificationRow( 50 | notification: group.notifications[0], 51 | postItem: group.postItem, 52 | actionText: "quoted you" 53 | ) 54 | default: 55 | EmptyView() 56 | } 57 | } 58 | .listRowSeparator(.hidden) 59 | } 60 | 61 | if cursor != nil { 62 | ProgressView() 63 | .task { 64 | await fetchNotifications() 65 | } 66 | } 67 | } 68 | .listStyle(.plain) 69 | .task { 70 | cursor = nil 71 | await fetchNotifications() 72 | } 73 | .refreshable { 74 | cursor = nil 75 | await fetchNotifications() 76 | } 77 | } 78 | 79 | private func fetchNotifications() async { 80 | do { 81 | if let cursor { 82 | let response = try await client.protoClient.listNotifications( 83 | isPriority: false, cursor: cursor) 84 | self.notificationsGroups.append( 85 | contentsOf: await NotificationsGroup.groupNotifications( 86 | client: client, response.notifications) 87 | ) 88 | self.cursor = response.cursor 89 | } else { 90 | let response = try await client.protoClient.listNotifications(isPriority: false) 91 | self.notificationsGroups = await NotificationsGroup.groupNotifications( 92 | client: client, response.notifications) 93 | self.cursor = response.cursor 94 | } 95 | } catch { 96 | print(error) 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Packages/Features/Sources/NotificationsUI/Rows/GroupedNotificationRow.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Destinations 5 | import PostUI 6 | import SwiftUI 7 | 8 | struct GroupedNotificationRow: View { 9 | @Environment(AppRouter.self) var router 10 | 11 | let group: NotificationsGroup 12 | let actionText: (Int) -> String // Closure to generate action text based on count 13 | 14 | var body: some View { 15 | VStack(alignment: .leading) { 16 | if group.postItem != nil { 17 | HStack(alignment: .top) { 18 | actionTextView 19 | Spacer() 20 | Text(group.timestamp.relativeFormatted) 21 | .foregroundStyle(.secondary) 22 | } 23 | ZStack(alignment: .bottomTrailing) { 24 | postView 25 | avatarsView 26 | .offset(x: group.postItem == nil ? 0 : 6, y: 24) 27 | NotificationIconView( 28 | icon: group.type.iconName, 29 | color: group.type.color 30 | ) 31 | .offset(x: 6, y: 18) 32 | } 33 | } else { 34 | ZStack(alignment: .bottomTrailing) { 35 | avatarsView 36 | .offset(x: group.postItem == nil ? 0 : 6) 37 | NotificationIconView( 38 | icon: group.type.iconName, 39 | color: group.type.color 40 | ) 41 | .offset(x: 0, y: -4) 42 | } 43 | HStack(alignment: .top) { 44 | actionTextView 45 | Spacer() 46 | Text(group.timestamp.relativeFormatted) 47 | .foregroundStyle(.secondary) 48 | } 49 | } 50 | } 51 | .padding(.vertical, 12) 52 | .padding(.bottom, 6) 53 | } 54 | 55 | @ViewBuilder 56 | private var postView: some View { 57 | if let post = group.postItem { 58 | HStack(alignment: .top) { 59 | PostRowBodyView(post: post) 60 | .foregroundStyle(.secondary) 61 | Spacer() 62 | PostRowEmbedView(post: post) 63 | } 64 | .environment(\.isQuote, true) 65 | .padding(8) 66 | .background { 67 | RoundedRectangle(cornerRadius: 8) 68 | .stroke( 69 | LinearGradient( 70 | colors: [group.type.color, .indigo], 71 | startPoint: .topLeading, 72 | endPoint: .bottomTrailing), 73 | lineWidth: 1 74 | ) 75 | .shadow(color: group.type.color.opacity(0.5), radius: 3) 76 | } 77 | .contentShape(Rectangle()) 78 | .onTapGesture { 79 | router.navigateTo(RouterDestination.post(post)) 80 | } 81 | } 82 | } 83 | 84 | private var avatarsView: some View { 85 | HStack(spacing: -10) { 86 | ForEach(group.notifications.prefix(5), id: \.uri) { notification in 87 | AsyncImage(url: notification.author.avatarImageURL) { image in 88 | image.resizable() 89 | } placeholder: { 90 | Circle() 91 | .fill(Color.blueskyBackground) 92 | } 93 | .frame(width: 30, height: 30) 94 | .clipShape(Circle()) 95 | .overlay( 96 | Circle().stroke( 97 | LinearGradient( 98 | colors: [group.type.color, .indigo], 99 | startPoint: .topLeading, 100 | endPoint: .bottomTrailing 101 | ), 102 | lineWidth: 1 103 | ) 104 | ) 105 | } 106 | } 107 | .padding(.trailing, 12) 108 | } 109 | 110 | @ViewBuilder 111 | private var actionTextView: some View { 112 | if group.notifications.count == 1 { 113 | Text( 114 | group.notifications[0].author.displayName ?? group.notifications[0].author.actorHandle 115 | ) 116 | .fontWeight(.semibold) 117 | + Text(actionText(1)) 118 | .foregroundStyle(.secondary) 119 | } else { 120 | Text( 121 | group.notifications[0].author.displayName ?? group.notifications[0].author.actorHandle 122 | ) 123 | .fontWeight(.semibold) 124 | + Text(actionText(group.notifications.count)) 125 | .foregroundStyle(.secondary) 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Packages/Features/Sources/NotificationsUI/Rows/NotificationIconView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct NotificationIconView: View { 4 | let icon: String 5 | let color: Color 6 | 7 | var body: some View { 8 | Image(systemName: icon) 9 | .foregroundStyle( 10 | color 11 | .shadow(.drop(color: color.opacity(0.5), radius: 2)) 12 | ) 13 | .shadow(color: color, radius: 1) 14 | .background( 15 | Circle() 16 | .fill(.thickMaterial) 17 | .stroke( 18 | LinearGradient( 19 | colors: [color, .indigo], 20 | startPoint: .topLeading, 21 | endPoint: .bottomTrailing), 22 | lineWidth: 1 23 | ) 24 | .frame(width: 30, height: 30) 25 | .shadow(color: color.opacity(0.5), radius: 3) 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Packages/Features/Sources/NotificationsUI/Rows/SingleNotificationRow.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import Destinations 4 | import Models 5 | import Network 6 | import PostUI 7 | import SwiftUI 8 | 9 | struct SingleNotificationRow: View { 10 | @Environment(AppRouter.self) var router 11 | @Environment(BSkyClient.self) var client 12 | @Environment(PostContextProvider.self) var postDataControllerProvider 13 | 14 | let notification: AppBskyLexicon.Notification.Notification 15 | let postItem: PostItem? 16 | let actionText: String 17 | 18 | var body: some View { 19 | HStack(alignment: .top) { 20 | AsyncImage(url: notification.author.avatarImageURL) { image in 21 | image 22 | .resizable() 23 | .scaledToFit() 24 | .clipShape(Circle()) 25 | } placeholder: { 26 | Color.gray 27 | } 28 | .frame(width: 40, height: 40) 29 | .overlay { 30 | Circle() 31 | .stroke( 32 | LinearGradient( 33 | colors: [.shadowPrimary.opacity(0.5), .indigo.opacity(0.5)], 34 | startPoint: .topLeading, 35 | endPoint: .bottomTrailing), 36 | lineWidth: 1) 37 | } 38 | .shadow(color: .shadowPrimary.opacity(0.3), radius: 2) 39 | 40 | VStack(alignment: .leading) { 41 | Text(notification.author.displayName ?? "") 42 | .fontWeight(.semibold) 43 | HStack { 44 | Image(systemName: notification.reason.iconName) 45 | Text(actionText) 46 | } 47 | .foregroundStyle(.secondary) 48 | 49 | if let postItem { 50 | VStack(alignment: .leading, spacing: 8) { 51 | PostRowBodyView(post: postItem) 52 | PostRowEmbedView(post: postItem) 53 | PostRowActionsView(post: postItem) 54 | .environment(\.hideMoreActions, true) 55 | } 56 | .padding(.top, 8) 57 | .contentShape(Rectangle()) 58 | .onTapGesture { 59 | router.navigateTo(RouterDestination.post(postItem)) 60 | } 61 | .environment(postDataControllerProvider.get(for: postItem, client: client)) 62 | } 63 | } 64 | 65 | Spacer() 66 | 67 | Text(notification.indexedAt.relativeFormatted) 68 | .foregroundStyle(.secondary) 69 | } 70 | .padding(.vertical, 8) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/Detail/PostDetailView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Models 5 | import Network 6 | import SwiftUI 7 | 8 | public struct PostDetailView: View { 9 | @Environment(BSkyClient.self) var client 10 | 11 | @State var post: PostItem 12 | @State var parents: [PostItem] = [] 13 | @State var replies: [PostItem] = [] 14 | 15 | @State private var scrollToId: String? 16 | 17 | public init(post: PostItem) { 18 | self.post = post 19 | } 20 | 21 | public var body: some View { 22 | ScrollViewReader { proxy in 23 | List { 24 | HeaderView(title: "Post") 25 | .padding(.bottom) 26 | 27 | ForEach(parents) { parent in 28 | PostRowView(post: parent) 29 | } 30 | 31 | PostRowView(post: post) 32 | .environment(\.isFocused, true) 33 | .id("focusedPost") 34 | 35 | ForEach(replies) { reply in 36 | PostRowView(post: reply) 37 | } 38 | 39 | VStack {} 40 | .frame(height: 300) 41 | .listRowSeparator(.hidden) 42 | } 43 | .screenContainer() 44 | .task { 45 | await fetchThread() 46 | if !parents.isEmpty { 47 | scrollToId = "focusedPost" 48 | } 49 | } 50 | .onChange(of: scrollToId) { 51 | if let scrollToId { 52 | proxy.scrollTo(scrollToId, anchor: .top) 53 | } 54 | } 55 | } 56 | } 57 | 58 | private func fetchThread() async { 59 | do { 60 | let thread = try await client.protoClient.getPostThread(from: post.uri) 61 | switch thread.thread { 62 | case .threadViewPost(let threadViewPost): 63 | self.post = threadViewPost.post.postItem 64 | processParents(from: threadViewPost) 65 | processReplies(from: threadViewPost) 66 | default: 67 | break 68 | } 69 | } catch { 70 | print(error) 71 | } 72 | } 73 | 74 | private func processParents(from threadViewPost: AppBskyLexicon.Feed.ThreadViewPostDefinition) { 75 | if let parent = threadViewPost.parent { 76 | switch parent { 77 | case .threadViewPost(let post): 78 | var item = post.post.postItem 79 | item.hasReply = true 80 | self.parents.append(item) 81 | processParents(from: post) 82 | default: 83 | break 84 | } 85 | } 86 | } 87 | 88 | private func processReplies(from threadViewPost: AppBskyLexicon.Feed.ThreadViewPostDefinition) { 89 | if let replies = threadViewPost.replies { 90 | for reply in replies { 91 | switch reply { 92 | case .threadViewPost(let reply): 93 | var postItem = reply.post.postItem 94 | if reply.replies?.isEmpty == false { 95 | postItem.hasReply = true 96 | } 97 | self.replies.append(postItem) 98 | processReplies(from: reply) 99 | default: 100 | break 101 | } 102 | } 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/List/Base/PostsListView.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import DesignSystem 3 | import Models 4 | import Network 5 | import SwiftUI 6 | import User 7 | 8 | public struct PostListView: View { 9 | let datasource: PostsListViewDatasource 10 | @State private var state: PostsListViewState = .uninitialized 11 | 12 | public var body: some View { 13 | List { 14 | HeaderView(title: datasource.title) 15 | .padding(.bottom) 16 | 17 | switch state { 18 | case .loading, .uninitialized: 19 | placeholderView 20 | case let .loaded(posts, cursor): 21 | ForEach(posts) { post in 22 | PostRowView(post: post) 23 | } 24 | if cursor != nil { 25 | nextPageView 26 | } 27 | case let .error(error): 28 | Text(error.localizedDescription) 29 | } 30 | } 31 | .screenContainer() 32 | .task { 33 | if case .uninitialized = state { 34 | state = .loading 35 | state = await datasource.loadPosts(with: state) 36 | } 37 | } 38 | .refreshable { 39 | state = .loading 40 | state = await datasource.loadPosts(with: state) 41 | } 42 | } 43 | 44 | private var nextPageView: some View { 45 | HStack { 46 | ProgressView() 47 | } 48 | .task { 49 | state = await datasource.loadPosts(with: state) 50 | } 51 | } 52 | 53 | private var placeholderView: some View { 54 | ForEach(PostItem.placeholders) { post in 55 | PostRowView(post: post) 56 | .redacted(reason: .placeholder) 57 | .allowsHitTesting(false) 58 | } 59 | } 60 | } 61 | 62 | // MARK: - Data 63 | extension PostListView { 64 | public static func processFeed(_ feed: [AppBskyLexicon.Feed.FeedViewPostDefinition]) -> [PostItem] 65 | { 66 | var postItems: [PostItem] = [] 67 | 68 | func insert(post: AppBskyLexicon.Feed.PostViewDefinition, hasReply: Bool) { 69 | guard !postItems.contains(where: { $0.uri == post.postItem.uri }) else { return } 70 | var item = post.postItem 71 | item.hasReply = hasReply 72 | postItems.append(item) 73 | } 74 | 75 | for post in feed { 76 | if let reply = post.reply { 77 | switch reply.root { 78 | case let .postView(post): 79 | insert(post: post, hasReply: true) 80 | 81 | switch reply.parent { 82 | case let .postView(parent): 83 | insert(post: parent, hasReply: true) 84 | default: 85 | break 86 | } 87 | default: 88 | break 89 | } 90 | } 91 | insert(post: post.post, hasReply: false) 92 | 93 | } 94 | return postItems 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/List/Base/PostsListViewDatasource.swift: -------------------------------------------------------------------------------- 1 | import Models 2 | import SwiftUI 3 | 4 | @MainActor 5 | protocol PostsListViewDatasource { 6 | var title: String { get } 7 | func loadPosts(with state: PostsListViewState) async -> PostsListViewState 8 | } 9 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/List/Base/PostsListViewState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Models 3 | 4 | enum PostsListViewState { 5 | case uninitialized 6 | case loading 7 | case loaded(posts: [PostItem], cursor: String?) 8 | case error(Error) 9 | } 10 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/List/PostsFeedView.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import DesignSystem 3 | import Models 4 | import Network 5 | import SwiftData 6 | import SwiftUI 7 | import User 8 | 9 | public struct PostsFeedView: View { 10 | @Environment(BSkyClient.self) var client 11 | @Environment(\.modelContext) var modelContext 12 | 13 | private let feedItem: FeedItem 14 | 15 | public init(feedItem: FeedItem) { 16 | self.feedItem = feedItem 17 | } 18 | 19 | public var body: some View { 20 | PostListView(datasource: self) 21 | .onAppear { 22 | updateRecentlyViewed() 23 | } 24 | } 25 | 26 | private func updateRecentlyViewed() { 27 | do { 28 | try modelContext.delete( 29 | model: RecentFeedItem.self, 30 | where: #Predicate { feed in 31 | feed.uri == feedItem.uri 32 | }) 33 | modelContext.insert( 34 | RecentFeedItem( 35 | uri: feedItem.uri, 36 | name: feedItem.displayName, 37 | avatarImageURL: feedItem.avatarImageURL, 38 | lastViewedAt: Date() 39 | ) 40 | ) 41 | try modelContext.save() 42 | } catch {} 43 | } 44 | } 45 | 46 | // MARK: - Datasource 47 | extension PostsFeedView: PostsListViewDatasource { 48 | var title: String { 49 | feedItem.displayName 50 | } 51 | 52 | func loadPosts(with state: PostsListViewState) async -> PostsListViewState { 53 | do { 54 | switch state { 55 | case .uninitialized, .loading, .error: 56 | let feed = try await client.protoClient.getFeed(by: feedItem.uri, cursor: nil) 57 | return .loaded(posts: PostListView.processFeed(feed.feed), cursor: feed.cursor) 58 | case let .loaded(posts, cursor): 59 | let feed = try await client.protoClient.getFeed(by: feedItem.uri, cursor: cursor) 60 | return .loaded(posts: posts + PostListView.processFeed(feed.feed), cursor: feed.cursor) 61 | } 62 | } catch { 63 | return .error(error) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/List/PostsLikesView.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import DesignSystem 3 | import Models 4 | import Network 5 | import SwiftUI 6 | import User 7 | 8 | public struct PostsLikesView: View { 9 | @Environment(BSkyClient.self) var client 10 | 11 | let profile: Profile 12 | 13 | public init(profile: Profile) { 14 | self.profile = profile 15 | } 16 | 17 | public var body: some View { 18 | PostListView(datasource: self) 19 | } 20 | } 21 | 22 | // MARK: - Datasource 23 | extension PostsLikesView: PostsListViewDatasource { 24 | var title: String { 25 | "Likes" 26 | } 27 | 28 | func loadPosts(with state: PostsListViewState) async -> PostsListViewState { 29 | do { 30 | switch state { 31 | case .uninitialized, .loading, .error: 32 | let feed = try await client.protoClient.getActorLikes(by: profile.did) 33 | return .loaded(posts: PostListView.processFeed(feed.feed), cursor: feed.cursor) 34 | case let .loaded(posts, cursor): 35 | let feed = try await client.protoClient.getActorLikes( 36 | by: profile.did, limit: nil, cursor: cursor) 37 | return .loaded(posts: posts + PostListView.processFeed(feed.feed), cursor: feed.cursor) 38 | } 39 | } catch { 40 | return .error(error) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/List/PostsProfileView.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import DesignSystem 3 | import Models 4 | import Network 5 | import SwiftUI 6 | import User 7 | 8 | public struct PostsProfileView: View { 9 | @Environment(BSkyClient.self) var client 10 | 11 | let profile: Profile 12 | let filter: PostsProfileViewFilter 13 | 14 | public init(profile: Profile, filter: PostsProfileViewFilter) { 15 | self.profile = profile 16 | self.filter = filter 17 | } 18 | 19 | public var body: some View { 20 | PostListView(datasource: self) 21 | } 22 | } 23 | 24 | // MARK: - Datasource 25 | extension PostsProfileView: PostsListViewDatasource { 26 | var title: String { 27 | "Posts" 28 | } 29 | 30 | func loadPosts(with state: PostsListViewState) async -> PostsListViewState { 31 | do { 32 | switch state { 33 | case .uninitialized, .loading, .error: 34 | let feed = try await client.protoClient.getAuthorFeed( 35 | by: profile.did, postFilter: filter.atProtocolFilter) 36 | return .loaded(posts: PostListView.processFeed(feed.feed), cursor: feed.cursor) 37 | case let .loaded(posts, cursor): 38 | let feed = try await client.protoClient.getAuthorFeed( 39 | by: profile.did, limit: nil, cursor: cursor, postFilter: filter.atProtocolFilter) 40 | return .loaded(posts: posts + PostListView.processFeed(feed.feed), cursor: feed.cursor) 41 | } 42 | } catch { 43 | return .error(error) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/List/PostsTimelineView.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import DesignSystem 3 | import Models 4 | import Network 5 | import SwiftUI 6 | import User 7 | 8 | public struct PostsTimelineView: View { 9 | @Environment(BSkyClient.self) var client 10 | 11 | public init() {} 12 | 13 | public var body: some View { 14 | PostListView(datasource: self) 15 | } 16 | } 17 | 18 | // MARK: - Datasource 19 | extension PostsTimelineView: PostsListViewDatasource { 20 | var title: String { 21 | "Following" 22 | } 23 | 24 | func loadPosts(with state: PostsListViewState) async -> PostsListViewState { 25 | do { 26 | switch state { 27 | case .uninitialized, .loading, .error: 28 | let feed = try await client.protoClient.getTimeline() 29 | return .loaded(posts: PostListView.processFeed(feed.feed), cursor: feed.cursor) 30 | case let .loaded(posts, cursor): 31 | let feed = try await client.protoClient.getTimeline(cursor: cursor) 32 | return .loaded(posts: posts + PostListView.processFeed(feed.feed), cursor: feed.cursor) 33 | } 34 | } catch { 35 | return .error(error) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/Row/PostRowActionsView.swift: -------------------------------------------------------------------------------- 1 | import Models 2 | import Network 3 | import SwiftUI 4 | 5 | extension EnvironmentValues { 6 | @Entry public var hideMoreActions = false 7 | } 8 | 9 | public struct PostRowActionsView: View { 10 | @Environment(\.hideMoreActions) var hideMoreActions 11 | @Environment(PostContext.self) var dataController 12 | 13 | let post: PostItem 14 | 15 | public init(post: PostItem) { 16 | self.post = post 17 | } 18 | 19 | public var body: some View { 20 | HStack(alignment: .firstTextBaseline, spacing: 16) { 21 | Button(action: {}) { 22 | Label("\(post.replyCount)", systemImage: "bubble") 23 | } 24 | .buttonStyle(.plain) 25 | .foregroundStyle( 26 | LinearGradient( 27 | colors: [.indigo, .purple], 28 | startPoint: .top, 29 | endPoint: .bottom 30 | ) 31 | ) 32 | 33 | Button(action: {}) { 34 | Label("\(dataController.repostCount)", systemImage: "quote.bubble") 35 | .contentTransition(.numericText(value: Double(dataController.repostCount))) 36 | .monospacedDigit() 37 | .lineLimit(1) 38 | .animation(.smooth, value: dataController.repostCount) 39 | } 40 | .buttonStyle(.plain) 41 | .symbolVariant(dataController.isReposted ? .fill : .none) 42 | .foregroundStyle( 43 | LinearGradient( 44 | colors: [.purple, .indigo], 45 | startPoint: .top, 46 | endPoint: .bottom 47 | ) 48 | ) 49 | 50 | Button(action: { 51 | Task { 52 | await dataController.toggleLike() 53 | } 54 | }) { 55 | Label("\(dataController.likeCount)", systemImage: "heart") 56 | .lineLimit(1) 57 | } 58 | .buttonStyle(.plain) 59 | .symbolVariant(dataController.isLiked ? .fill : .none) 60 | .symbolEffect(.bounce, value: dataController.isLiked) 61 | .contentTransition(.numericText(value: Double(dataController.likeCount))) 62 | .monospacedDigit() 63 | .animation(.smooth, value: dataController.likeCount) 64 | .foregroundStyle( 65 | LinearGradient( 66 | colors: [.red, .purple], 67 | startPoint: .topLeading, 68 | endPoint: .bottomTrailing 69 | ) 70 | ) 71 | 72 | Spacer() 73 | 74 | if !hideMoreActions { 75 | Button(action: {}) { 76 | Image(systemName: "ellipsis") 77 | } 78 | .buttonStyle(.plain) 79 | .foregroundStyle( 80 | LinearGradient( 81 | colors: [.indigo, .purple], 82 | startPoint: .leading, 83 | endPoint: .trailing 84 | ) 85 | ) 86 | } 87 | } 88 | .buttonStyle(.plain) 89 | .labelStyle(.customSpacing(4)) 90 | .font(.callout) 91 | .padding(.top, 8) 92 | .padding(.bottom, 16) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/Row/PostRowBodyView.swift: -------------------------------------------------------------------------------- 1 | import Models 2 | import SwiftUI 3 | 4 | public struct PostRowBodyView: View { 5 | @Environment(\.isFocused) private var isFocused 6 | 7 | let post: PostItem 8 | 9 | public init(post: PostItem) { 10 | self.post = post 11 | } 12 | 13 | public var body: some View { 14 | Text(post.content) 15 | .font(isFocused ? .system(size: UIFontMetrics.default.scaledValue(for: 20)) : .body) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/Row/PostRowEmbedExternalView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import DesignSystem 3 | import Models 4 | import SwiftUI 5 | 6 | struct PostRowEmbedExternalView: View { 7 | @Environment(\.openURL) var openURL 8 | 9 | let externalView: AppBskyLexicon.Embed.ExternalDefinition.View 10 | 11 | var body: some View { 12 | VStack(alignment: .leading, spacing: 8) { 13 | GeometryReader { proxy in 14 | AsyncImage(url: externalView.external.thumbnailImageURL) { phase in 15 | switch phase { 16 | case .success(let image): 17 | image 18 | .resizable() 19 | .aspectRatio(contentMode: .fill) 20 | .frame(width: proxy.size.width) 21 | .frame(height: 200) 22 | .clipped() 23 | default: 24 | Rectangle() 25 | .fill(Color.gray.opacity(0.2)) 26 | } 27 | } 28 | } 29 | .frame(height: 200) 30 | 31 | Text(externalView.external.title) 32 | .font(.headline) 33 | .fontWeight(.semibold) 34 | .foregroundStyle(.primary) 35 | .lineLimit(3) 36 | .fixedSize(horizontal: false, vertical: true) 37 | .padding(.horizontal, 8) 38 | .padding(.top, 8) 39 | Text(externalView.external.description) 40 | .font(.body) 41 | .foregroundStyle(.secondary) 42 | .lineLimit(2) 43 | .fixedSize(horizontal: false, vertical: true) 44 | .padding(.horizontal, 8) 45 | .padding(.bottom, 8) 46 | } 47 | .background( 48 | RoundedRectangle(cornerRadius: 8) 49 | .fill(.ultraThinMaterial) 50 | ) 51 | .glowingRoundedRectangle() 52 | .contentShape(Rectangle()) 53 | .onTapGesture { 54 | if let url = URL(string: externalView.external.uri) { 55 | openURL(url) 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/Row/PostRowEmbedQuoteView.swift: -------------------------------------------------------------------------------- 1 | import AppRouter 2 | import Destinations 3 | import Models 4 | import SwiftUI 5 | 6 | struct PostRowEmbedQuoteView: View { 7 | @Environment(\.currentTab) var currentTab 8 | @Environment(AppRouter.self) var router 9 | 10 | let post: PostItem 11 | 12 | var body: some View { 13 | PostRowView(post: post) 14 | .environment(\.isQuote, true) 15 | .padding(8) 16 | .background(.thinMaterial) 17 | .overlay { 18 | RoundedRectangle(cornerRadius: 8) 19 | .stroke( 20 | LinearGradient( 21 | colors: [.shadowPrimary.opacity(0.5), .indigo.opacity(0.5)], 22 | startPoint: .topLeading, 23 | endPoint: .bottomTrailing), 24 | lineWidth: 1 25 | ) 26 | .shadow(color: .shadowPrimary.opacity(0.3), radius: 1) 27 | } 28 | .clipShape(RoundedRectangle(cornerRadius: 8)) 29 | .shadow(color: .indigo.opacity(0.3), radius: 2) 30 | .onTapGesture { 31 | router.navigateTo(.post(post)) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/Row/PostRowEmbedView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import DesignSystem 3 | import Models 4 | import SwiftUI 5 | 6 | public struct PostRowEmbedView: View { 7 | @Environment(\.isQuote) var isQuote 8 | 9 | let post: PostItem 10 | 11 | public init(post: PostItem) { 12 | self.post = post 13 | } 14 | 15 | public var body: some View { 16 | if let embed = post.embed { 17 | switch embed { 18 | case .embedImagesView(let images): 19 | PostRowImagesView(images: images) 20 | case .embedExternalView(let externalView): 21 | if isQuote { 22 | EmptyView() 23 | } else { 24 | PostRowEmbedExternalView(externalView: externalView) 25 | } 26 | case .embedRecordView(let record): 27 | switch record.record { 28 | case .viewRecord(let post): 29 | if isQuote { 30 | EmptyView() 31 | } else { 32 | PostRowEmbedQuoteView(post: post.postItem) 33 | } 34 | default: 35 | EmptyView() 36 | } 37 | default: 38 | EmptyView() 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/Row/PostRowImagesView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Destinations 5 | import Models 6 | import NukeUI 7 | import SwiftUI 8 | 9 | struct PostRowImagesView: View { 10 | @Environment(\.isQuote) var isQuote 11 | @Environment(AppRouter.self) var router 12 | 13 | @Namespace private var namespace 14 | 15 | let quoteMaxSize: CGFloat = 100 16 | let images: AppBskyLexicon.Embed.ImagesDefinition.View 17 | 18 | @State private var firstImageSize: CGSize? 19 | @State private var isMediaExpanded: Bool = false 20 | @State private var shouldRotate = true 21 | 22 | var body: some View { 23 | ZStack(alignment: .topLeading) { 24 | ForEach(images.images.indices.reversed(), id: \.self) { index in 25 | makeImageView(image: images.images[index], index: index) 26 | .frame(maxWidth: isQuote ? quoteMaxSize : nil) 27 | .rotationEffect( 28 | index == images.images.indices.first 29 | ? .degrees(0) : .degrees(shouldRotate ? Double(index) * -2 : 0), 30 | anchor: .bottomTrailing 31 | ) 32 | } 33 | } 34 | .padding(.bottom, images.images.count > 1 && !isQuote ? CGFloat(images.images.count) * 7 : 0) 35 | .onTapGesture { 36 | withAnimation(.easeInOut(duration: 0.1)) { 37 | shouldRotate = false 38 | } completion: { 39 | router.presentedSheet = .fullScreenMedia( 40 | images: images.images.map { .init(url: $0.fullSizeImageURL, alt: $0.altText) }, 41 | preloadedImage: images.images.first?.thumbnailImageURL, 42 | namespace: namespace 43 | ) 44 | } 45 | } 46 | .onChange(of: router.presentedSheet) { 47 | if router.presentedSheet == nil { 48 | withAnimation(.bouncy) { 49 | shouldRotate = true 50 | } 51 | } 52 | } 53 | } 54 | 55 | @ViewBuilder 56 | private func makeImageView(image: AppBskyLexicon.Embed.ImagesDefinition.ViewImage, index: Int) 57 | -> some View 58 | { 59 | let width: CGFloat = CGFloat(image.aspectRatio?.width ?? 300) 60 | let height: CGFloat = CGFloat(image.aspectRatio?.height ?? 200) 61 | GeometryReader { geometry in 62 | let displayWidth = isQuote ? quoteMaxSize : min(geometry.size.width, width) 63 | let displayHeight = isQuote ? quoteMaxSize : displayWidth / (width / height) 64 | let finalWidth = firstImageSize?.width ?? displayWidth 65 | let finalHeight = firstImageSize?.height ?? displayHeight 66 | LazyImage(url: image.thumbnailImageURL) { state in 67 | if let image = state.image { 68 | image 69 | .resizable() 70 | .scaledToFill() 71 | .aspectRatio(contentMode: index == images.images.indices.first ? .fit : .fill) 72 | } else { 73 | RoundedRectangle(cornerRadius: 8) 74 | .fill(.thinMaterial) 75 | } 76 | } 77 | .processors([.resize(size: .init(width: finalWidth, height: finalHeight))]) 78 | .frame(width: finalWidth, height: finalHeight) 79 | .matchedTransitionSource(id: image.fullSizeImageURL, in: namespace) 80 | .clipShape(.rect(cornerRadius: 8)) 81 | .overlay { 82 | RoundedRectangle(cornerRadius: 8) 83 | .stroke( 84 | LinearGradient( 85 | colors: [.shadowPrimary.opacity(0.3), .indigo.opacity(0.5)], 86 | startPoint: .topLeading, 87 | endPoint: .bottomTrailing), 88 | lineWidth: 1) 89 | } 90 | .shadow(color: .indigo.opacity(0.3), radius: 3) 91 | .onAppear { 92 | if index == images.images.indices.first { 93 | self.firstImageSize = CGSize(width: displayWidth, height: displayHeight) 94 | } 95 | } 96 | } 97 | .aspectRatio( 98 | isQuote ? 1 : (firstImageSize?.width ?? width) / (firstImageSize?.height ?? height), 99 | contentMode: .fit) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/Row/PostRowView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Destinations 5 | import Models 6 | import Network 7 | import SwiftUI 8 | import User 9 | 10 | extension EnvironmentValues { 11 | @Entry public var isQuote: Bool = false 12 | @Entry public var isFocused: Bool = false 13 | } 14 | 15 | public struct PostRowView: View { 16 | @Environment(\.isQuote) var isQuote 17 | @Environment(\.isFocused) var isFocused 18 | @Environment(\.sizeCategory) var sizeCategory 19 | 20 | @Environment(PostContextProvider.self) var postDataControllerProvider 21 | @Environment(AppRouter.self) var router 22 | @Environment(BSkyClient.self) var client 23 | 24 | let post: PostItem 25 | 26 | public init(post: PostItem) { 27 | self.post = post 28 | } 29 | 30 | public var body: some View { 31 | HStack(alignment: .top, spacing: 8) { 32 | if !isQuote { 33 | VStack(spacing: 0) { 34 | avatarView 35 | threadLineView 36 | } 37 | } 38 | mainView 39 | .padding(.bottom, 18) 40 | } 41 | .environment(postDataControllerProvider.get(for: post, client: client)) 42 | .listRowSeparator(.hidden) 43 | .listRowInsets(.init(top: 0, leading: 18, bottom: 0, trailing: 18)) 44 | } 45 | 46 | private var mainView: some View { 47 | VStack(alignment: .leading, spacing: 8) { 48 | authorView 49 | PostRowBodyView(post: post) 50 | PostRowEmbedView(post: post) 51 | if !isQuote { 52 | PostRowActionsView(post: post) 53 | } 54 | } 55 | .contentShape(Rectangle()) 56 | .onTapGesture { 57 | router.navigateTo(.post(post)) 58 | } 59 | } 60 | 61 | private var avatarView: some View { 62 | AsyncImage(url: post.author.avatarImageURL) { phase in 63 | switch phase { 64 | case .success(let image): 65 | image 66 | .resizable() 67 | .scaledToFit() 68 | .frame(width: isQuote ? 16 : 40, height: isQuote ? 16 : 40) 69 | .clipShape(Circle()) 70 | default: 71 | Circle() 72 | .fill(.gray.opacity(0.2)) 73 | .frame(width: isQuote ? 16 : 40, height: isQuote ? 16 : 40) 74 | } 75 | } 76 | .overlay { 77 | Circle() 78 | .stroke( 79 | LinearGradient( 80 | colors: post.hasReply 81 | ? [.purple, .indigo] : [.shadowPrimary.opacity(0.5), .indigo.opacity(0.5)], 82 | startPoint: .topLeading, 83 | endPoint: .bottomTrailing), 84 | lineWidth: 1) 85 | } 86 | .shadow(color: .shadowPrimary.opacity(0.3), radius: 2) 87 | .onTapGesture { 88 | router.navigateTo(.profile(post.author)) 89 | } 90 | } 91 | 92 | private var authorView: some View { 93 | HStack(alignment: isQuote ? .center : .firstTextBaseline) { 94 | if isQuote { 95 | avatarView 96 | } 97 | Text(post.author.displayName ?? "") 98 | .font(.callout) 99 | .foregroundStyle(.primary) 100 | .fontWeight(.semibold) 101 | + Text(" @\(post.author.handle)") 102 | .font(.footnote) 103 | .foregroundStyle(.tertiary) 104 | Spacer() 105 | Text(post.indexAtFormatted) 106 | .font(.caption) 107 | .foregroundStyle(.secondary) 108 | } 109 | .lineLimit(1) 110 | .onTapGesture { 111 | router.navigateTo(.profile(post.author)) 112 | } 113 | } 114 | 115 | @ViewBuilder 116 | private var threadLineView: some View { 117 | if post.hasReply { 118 | Rectangle() 119 | .frame(width: 1) 120 | .frame(maxHeight: .infinity) 121 | .foregroundStyle( 122 | LinearGradient( 123 | colors: [.indigo, .purple], 124 | startPoint: .top, 125 | endPoint: .bottom 126 | ) 127 | .shadow(.drop(color: .indigo, radius: 3))) 128 | } 129 | } 130 | } 131 | 132 | #Preview { 133 | NavigationStack { 134 | List { 135 | PostRowView( 136 | post: .init( 137 | uri: "", 138 | cid: "", 139 | indexedAt: Date(), 140 | author: .init( 141 | did: "", 142 | handle: "dimillian", 143 | displayName: "Thomas Ricouard", 144 | avatarImageURL: nil), 145 | content: "Just some content", 146 | replyCount: 10, 147 | repostCount: 150, 148 | likeCount: 38, 149 | likeURI: nil, 150 | repostURI: nil, 151 | embed: nil, 152 | replyRef: nil)) 153 | PostRowView( 154 | post: .init( 155 | uri: "", 156 | cid: "", 157 | indexedAt: Date(), 158 | author: .init( 159 | did: "", 160 | handle: "dimillian", 161 | displayName: "Thomas Ricouard", 162 | avatarImageURL: nil), 163 | content: "Just some content", 164 | replyCount: 10, 165 | repostCount: 150, 166 | likeCount: 38, 167 | likeURI: nil, 168 | repostURI: nil, 169 | embed: nil, 170 | replyRef: nil)) 171 | PostRowEmbedQuoteView( 172 | post: .init( 173 | uri: "", 174 | cid: "", 175 | indexedAt: Date(), 176 | author: .init( 177 | did: "", 178 | handle: "dimillian", 179 | displayName: "Thomas Ricouard", 180 | avatarImageURL: nil), 181 | content: "Just some content", 182 | replyCount: 10, 183 | repostCount: 150, 184 | likeCount: 38, 185 | likeURI: "", 186 | repostURI: "", 187 | embed: nil, 188 | replyRef: nil)) 189 | } 190 | .listStyle(.plain) 191 | .environment(AppRouter(initialTab: .feed)) 192 | .environment(PostContextProvider()) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /Packages/Features/Sources/ProfileUI/CurrentUserView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import User 3 | 4 | public struct CurrentUserView: View { 5 | @Environment(CurrentUser.self) private var currentUser 6 | 7 | public init() {} 8 | 9 | public var body: some View { 10 | if let profile = currentUser.profile { 11 | ProfileView(profile: profile.profile, showBack: false) 12 | } else { 13 | ProgressView() 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Packages/Features/Sources/ProfileUI/ProfileView.swift: -------------------------------------------------------------------------------- 1 | import AppRouter 2 | import DesignSystem 3 | import Destinations 4 | import Models 5 | import PostUI 6 | import SwiftUI 7 | 8 | public struct ProfileView: View { 9 | @Environment(AppRouter.self) var router 10 | 11 | public let profile: Profile 12 | public let showBack: Bool 13 | 14 | public init(profile: Profile, showBack: Bool = true) { 15 | self.profile = profile 16 | self.showBack = showBack 17 | } 18 | 19 | public var body: some View { 20 | List { 21 | HeaderView( 22 | title: profile.displayName ?? profile.handle, 23 | subtitle: "@\(profile.handle)", 24 | showBack: showBack 25 | ) 26 | .padding(.bottom) 27 | 28 | NavigationLink( 29 | value: RouterDestination.profilePosts(profile: profile, filter: .postsWithNoReplies) 30 | ) { 31 | makeLabelButton(title: "Posts", icon: "bubble.fill", color: .blueskyBackground) 32 | } 33 | 34 | NavigationLink( 35 | value: RouterDestination.profilePosts(profile: profile, filter: .postsWithReplies) 36 | ) { 37 | makeLabelButton(title: "Replies", icon: "arrowshape.turn.up.left.fill", color: .teal) 38 | } 39 | 40 | NavigationLink( 41 | value: RouterDestination.profilePosts(profile: profile, filter: .postsWithMedia) 42 | ) { 43 | makeLabelButton(title: "Medias", icon: "photo.fill", color: .gray) 44 | } 45 | 46 | NavigationLink( 47 | value: RouterDestination.profilePosts(profile: profile, filter: .postAndAuthorThreads) 48 | ) { 49 | makeLabelButton(title: "Threads", icon: "bubble.left.and.bubble.right.fill", color: .green) 50 | } 51 | 52 | NavigationLink(value: RouterDestination.profileLikes(profile)) { 53 | makeLabelButton(title: "Likes", icon: "heart.fill", color: .red) 54 | } 55 | } 56 | .listStyle(.plain) 57 | .navigationBarBackButtonHidden() 58 | .toolbar(.hidden, for: .navigationBar) 59 | } 60 | 61 | private func makeLabelButton(title: String, icon: String, color: Color) -> some View { 62 | HStack { 63 | Image(systemName: icon) 64 | .foregroundColor(.white) 65 | .shadow(color: .white, radius: 3) 66 | .padding(12) 67 | .background( 68 | LinearGradient( 69 | colors: [color, .indigo], 70 | startPoint: .topLeading, 71 | endPoint: .bottomTrailing) 72 | ) 73 | .frame(width: 40, height: 40) 74 | .glowingRoundedRectangle() 75 | Text(title) 76 | .font(.title3) 77 | .fontWeight(.semibold) 78 | Spacer() 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Packages/Features/Sources/SettingsUI/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import Auth 2 | import DesignSystem 3 | import SwiftUI 4 | 5 | public struct SettingsView: View { 6 | @Environment(Auth.self) var auth 7 | 8 | public init() {} 9 | 10 | public var body: some View { 11 | Form { 12 | Section { 13 | HeaderView(title: "Settings", showBack: false) 14 | Button { 15 | Task { 16 | do { 17 | try await auth.logout() 18 | } catch {} 19 | } 20 | } label: { 21 | Text("Signout") 22 | .padding() 23 | } 24 | .buttonStyle(.pill) 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Packages/Features/Tests/DesignSystemTests/HeaderViewTests.swift: -------------------------------------------------------------------------------- 1 | import DesignSystem 2 | import SwiftUI 3 | import Testing 4 | import ViewInspector 5 | 6 | @MainActor 7 | struct HeaderViewTests { 8 | @Test func testHeaderViewTitle() throws { 9 | let title = "TestTitle" 10 | let headerView = HeaderView(title: title, showBack: false) 11 | #expect(try headerView.inspect().find(text: title).string() == title) 12 | } 13 | 14 | @Test func testHeaderViewBackButton() throws { 15 | let title = "TestTitle" 16 | let headerView = HeaderView(title: title, showBack: true) 17 | #expect(try headerView.inspect().find(viewWithId: "back").image().font() == .title) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Packages/Features/Tests/FeedUITests/FeedsListTitleViewTests.swift: -------------------------------------------------------------------------------- 1 | import FeedUI 2 | import SwiftUI 3 | import Testing 4 | import ViewInspector 5 | 6 | @MainActor 7 | struct FeedsListTitleViewTests { 8 | @FocusState var isSearchFocused: Bool 9 | @State var filter: FeedsListFilter = .myFeeds 10 | 11 | @Test func testFeedTitleViewBase() throws { 12 | let view = FeedsListTitleView( 13 | filter: $filter, 14 | searchText: .constant(""), 15 | isInSearch: .constant(false), 16 | isSearchFocused: $isSearchFocused) 17 | #expect(try view.inspect().find(text: "Feeds").string() == "Feeds") 18 | #expect( 19 | try view.inspect().find(text: filter.rawValue).string() == FeedsListFilter.myFeeds.rawValue) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Packages/Model/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Packages/Model/.swiftpm/xcode/xcshareddata/xcschemes/Auth.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 35 | 36 | 37 | 38 | 48 | 49 | 55 | 56 | 62 | 63 | 64 | 65 | 67 | 68 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /Packages/Model/.swiftpm/xcode/xcshareddata/xcschemes/AuthTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 19 | 25 | 26 | 27 | 28 | 29 | 39 | 40 | 46 | 47 | 49 | 50 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Packages/Model/.swiftpm/xcode/xcshareddata/xcschemes/Network.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Packages/Model/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "5c2652841b13d575ca17fe08490cd4dd7f28c5d084f030269ae52bbfc0073c77", 3 | "pins" : [ 4 | { 5 | "identity" : "approuter", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/Dimillian/AppRouter.git", 8 | "state" : { 9 | "revision" : "72f63f4312a231ab2706234b9e99c4b71651f38c", 10 | "version" : "1.0.2" 11 | } 12 | }, 13 | { 14 | "identity" : "atprotokit", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/dimillian/ATProtoKit", 17 | "state" : { 18 | "branch" : "remove-update", 19 | "revision" : "a817ae7c7757c89260df374f4b3cf575da6fcceb" 20 | } 21 | }, 22 | { 23 | "identity" : "keychain-swift", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/evgenyneu/keychain-swift", 26 | "state" : { 27 | "revision" : "5e1b02b6a9dac2a759a1d5dbc175c86bd192a608", 28 | "version" : "24.0.0" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-collections", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/apple/swift-collections.git", 35 | "state" : { 36 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 37 | "version" : "1.1.4" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-log", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-log.git", 44 | "state" : { 45 | "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", 46 | "version" : "1.6.1" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-syntax", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/swiftlang/swift-syntax.git", 53 | "state" : { 54 | "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", 55 | "version" : "509.1.1" 56 | } 57 | }, 58 | { 59 | "identity" : "swiftcbor", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/MasterJ93/SwiftCBOR.git", 62 | "state" : { 63 | "revision" : "0b01caa633889f6d64f73f1c01607358d36fe6e6", 64 | "version" : "0.4.0" 65 | } 66 | } 67 | ], 68 | "version" : 3 69 | } 70 | -------------------------------------------------------------------------------- /Packages/Model/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Model", 8 | platforms: [.iOS(.v18), .macOS(.v15)], 9 | products: [ 10 | .library(name: "Network", targets: ["Network"]), 11 | .library(name: "Models", targets: ["Models"]), 12 | .library(name: "Auth", targets: ["Auth"]), 13 | .library(name: "User", targets: ["User"]), 14 | .library(name: "Destinations", targets: ["Destinations"]), 15 | ], 16 | dependencies: [ 17 | .package( 18 | url: "https://github.com/dimillian/ATProtoKit", branch: "remove-update"), 19 | .package(url: "https://github.com/evgenyneu/keychain-swift", from: "24.0.0"), 20 | .package(url: "https://github.com/Dimillian/AppRouter.git", from: "1.0.2"), 21 | ], 22 | targets: [ 23 | .target( 24 | name: "Network", 25 | dependencies: [ 26 | .product(name: "ATProtoKit", package: "ATProtoKit") 27 | ] 28 | ), 29 | .target( 30 | name: "Models", 31 | dependencies: [ 32 | .product(name: "ATProtoKit", package: "ATProtoKit"), 33 | "Network", 34 | ] 35 | ), 36 | .target( 37 | name: "Auth", 38 | dependencies: [ 39 | .product(name: "ATProtoKit", package: "ATProtoKit"), 40 | .product(name: "KeychainSwift", package: "keychain-swift"), 41 | ] 42 | ), 43 | .testTarget( 44 | name: "AuthTests", 45 | dependencies: ["Auth"] 46 | ), 47 | .target( 48 | name: "User", 49 | dependencies: [ 50 | .product(name: "ATProtoKit", package: "ATProtoKit"), 51 | "Network", 52 | ] 53 | ), 54 | .target( 55 | name: "Destinations", 56 | dependencies: ["Models", "AppRouter"] 57 | ), 58 | ] 59 | ) 60 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Auth/Auth.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import Foundation 3 | @preconcurrency import KeychainSwift 4 | import SwiftUI 5 | 6 | @Observable 7 | public final class Auth: @unchecked Sendable { 8 | let keychain = KeychainSwift() 9 | 10 | public private(set) var sessionLastRefreshed = Date() 11 | 12 | public private(set) var configuration: ATProtocolConfiguration? 13 | 14 | private let ATProtoKeychain: AppleSecureKeychain 15 | 16 | public private(set) var authToken: String? { 17 | get { 18 | keychain.get("auth_token") 19 | } 20 | set { 21 | if let newValue { 22 | keychain.set(newValue, forKey: "auth_token") 23 | } else { 24 | keychain.delete("auth_token") 25 | } 26 | } 27 | } 28 | 29 | public private(set) var refreshToken: String? { 30 | get { 31 | keychain.get("refresh_token") 32 | } 33 | set { 34 | if let newValue { 35 | keychain.set(newValue, forKey: "refresh_token") 36 | } else { 37 | keychain.delete("refresh_token") 38 | } 39 | } 40 | } 41 | 42 | public func logout() async throws { 43 | try await configuration?.deleteSession() 44 | refreshToken = nil 45 | authToken = nil 46 | configuration = nil 47 | sessionLastRefreshed = Date() 48 | } 49 | 50 | public init() { 51 | if let uuid = keychain.get("session_uuid") { 52 | self.ATProtoKeychain = AppleSecureKeychain(identifier: .init(uuidString: uuid) ?? UUID()) 53 | } else { 54 | let newUUID = UUID().uuidString 55 | keychain.set(newUUID, forKey: "session_uuid") 56 | self.ATProtoKeychain = AppleSecureKeychain(identifier: .init(uuidString: newUUID) ?? UUID()) 57 | } 58 | 59 | } 60 | 61 | public func authenticate(handle: String, appPassword: String) async throws { 62 | defer { sessionLastRefreshed = Date() } 63 | let configuration = ATProtocolConfiguration(keychainProtocol: ATProtoKeychain) 64 | try await configuration.authenticate(with: handle, password: appPassword) 65 | self.authToken = try await configuration.keychainProtocol.retrieveAccessToken() 66 | self.refreshToken = try await configuration.keychainProtocol.retrieveRefreshToken() 67 | self.configuration = configuration 68 | } 69 | 70 | public func refresh() async { 71 | defer { sessionLastRefreshed = Date() } 72 | do { 73 | guard let authToken, let refreshToken else { return } 74 | let configuration = ATProtocolConfiguration(keychainProtocol: ATProtoKeychain) 75 | try await configuration.refreshSession() 76 | try await ATProtoKeychain.saveAccessToken(authToken) 77 | try await ATProtoKeychain.saveRefreshToken(refreshToken) 78 | self.authToken = try await configuration.keychainProtocol.retrieveAccessToken() 79 | self.refreshToken = try await configuration.keychainProtocol.retrieveRefreshToken() 80 | self.configuration = configuration 81 | } catch { 82 | self.configuration = nil 83 | } 84 | } 85 | 86 | } 87 | 88 | extension UserSession: @retroactive Equatable, @unchecked Sendable { 89 | public static func == (lhs: UserSession, rhs: UserSession) -> Bool { 90 | lhs.sessionDID == rhs.sessionDID 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Destinations/AppRouter.swift: -------------------------------------------------------------------------------- 1 | import AppRouter 2 | 3 | public typealias AppRouter = Router 4 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Destinations/AppTab.swift: -------------------------------------------------------------------------------- 1 | import AppRouter 2 | import SwiftUI 3 | 4 | extension EnvironmentValues { 5 | @Entry public var currentTab: AppTab = .feed 6 | } 7 | 8 | public enum AppTab: String, TabType { 9 | case feed, notification, messages, profile, settings 10 | 11 | public var id: String { rawValue } 12 | 13 | public var icon: String { 14 | switch self { 15 | case .feed: return "square.stack" 16 | case .notification: return "bell" 17 | case .messages: return "message" 18 | case .profile: return "person" 19 | case .settings: return "gearshape" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Destinations/RouterDestination.swift: -------------------------------------------------------------------------------- 1 | import AppRouter 2 | import Models 3 | import SwiftUI 4 | 5 | public enum RouterDestination: DestinationType, Hashable { 6 | public static func from(path: String, fullPath: [String], parameters: [String : String]) -> RouterDestination? { 7 | nil 8 | } 9 | 10 | case feed(FeedItem) 11 | case post(PostItem) 12 | case profile(Profile) 13 | case profilePosts(profile: Profile, filter: PostsProfileViewFilter) 14 | case profileLikes(Profile) 15 | case timeline 16 | } 17 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Destinations/SheetDestination.swift: -------------------------------------------------------------------------------- 1 | import AppRouter 2 | import Models 3 | import SwiftUI 4 | 5 | public enum SheetDestination: SheetType, Hashable, Identifiable { 6 | public var id: Int { self.hashValue } 7 | 8 | case auth 9 | case fullScreenMedia( 10 | images: [Media], 11 | preloadedImage: URL?, 12 | namespace: Namespace.ID) 13 | } 14 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Models/FeedItem.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import Foundation 3 | 4 | public struct FeedItem: Codable, Equatable, Identifiable, Hashable { 5 | public func hash(into hasher: inout Hasher) { 6 | hasher.combine(uri) 7 | } 8 | 9 | public var id: String { uri } 10 | public let uri: String 11 | public let displayName: String 12 | public let description: String? 13 | public let avatarImageURL: URL? 14 | public let creatorHandle: String 15 | public let likesCount: Int 16 | public let liked: Bool 17 | 18 | public init( 19 | uri: String, 20 | displayName: String, 21 | description: String?, 22 | avatarImageURL: URL?, 23 | creatorHandle: String, 24 | likesCount: Int, 25 | liked: Bool 26 | ) { 27 | self.uri = uri 28 | self.displayName = displayName 29 | self.description = description 30 | self.avatarImageURL = avatarImageURL 31 | self.creatorHandle = creatorHandle 32 | self.likesCount = likesCount 33 | self.liked = liked 34 | } 35 | } 36 | 37 | extension AppBskyLexicon.Feed.GeneratorViewDefinition { 38 | public var feedItem: FeedItem { 39 | FeedItem( 40 | uri: feedURI, 41 | displayName: displayName, 42 | description: description, 43 | avatarImageURL: avatarImageURL, 44 | creatorHandle: creator.actorHandle, 45 | likesCount: likeCount ?? 0, 46 | liked: viewer?.likeURI != nil 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Models/Media.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Media: Identifiable, Hashable { 4 | public var id: URL { url } 5 | public let url: URL 6 | public let alt: String? 7 | 8 | public init(url: URL, alt: String?) { 9 | self.url = url 10 | self.alt = alt?.isEmpty == true ? nil : alt 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Models/PorfileBasicViewExt.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | 3 | extension AppBskyLexicon.Actor.ProfileViewBasicDefinition { 4 | public var displayNameOrHandle: String { 5 | if let displayName = displayName, !displayName.isEmpty { 6 | return displayName 7 | } 8 | return actorHandle 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Models/PostContext.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import Foundation 3 | import Network 4 | import SwiftUI 5 | 6 | @MainActor 7 | @Observable 8 | public class PostContextProvider { 9 | private var contexts: [String: PostContext] = [:] 10 | 11 | public init() {} 12 | 13 | public func get(for post: PostItem, client: BSkyClient) -> PostContext { 14 | if let context = contexts[post.uri] { 15 | return context 16 | } else { 17 | let context = PostContext(post: post, client: client) 18 | contexts[post.uri] = context 19 | return context 20 | } 21 | } 22 | } 23 | 24 | @MainActor 25 | @Observable 26 | public class PostContext { 27 | private var post: PostItem 28 | private let client: BSkyClient 29 | 30 | public var isLiked: Bool { likeURI != nil } 31 | public var isReposted: Bool { repostURI != nil } 32 | 33 | public var likeCount: Int { post.likeCount + (isLiked ? 1 : 0) } 34 | public var repostCount: Int { post.repostCount + (isReposted ? 1 : 0) } 35 | 36 | private var likeURI: String? 37 | private var repostURI: String? 38 | 39 | public init(post: PostItem, client: BSkyClient) { 40 | self.post = post 41 | self.client = client 42 | 43 | likeURI = post.likeURI 44 | repostURI = post.repostURI 45 | } 46 | 47 | public func update(with post: PostItem) { 48 | self.post = post 49 | 50 | likeURI = post.likeURI 51 | repostURI = post.repostURI 52 | } 53 | 54 | public func toggleLike() async { 55 | let previousState = likeURI 56 | do { 57 | if let likeURI { 58 | self.likeURI = nil 59 | try await client.blueskyClient.deleteLikeRecord(.recordURI(atURI: likeURI)) 60 | } else { 61 | self.likeURI = try await client.blueskyClient.createLikeRecord( 62 | .init(recordURI: post.uri, cidHash: post.cid) 63 | ).recordURI 64 | } 65 | } catch { 66 | self.likeURI = previousState 67 | } 68 | } 69 | 70 | public func toggleRepost() async { 71 | // TODO: Implement 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Models/PostItem.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import Foundation 3 | 4 | public struct PostItem: Hashable, Identifiable, Equatable { 5 | public func hash(into hasher: inout Hasher) { 6 | hasher.combine(uri) 7 | } 8 | 9 | public var id: String { uri + uuid.uuidString } 10 | private let uuid = UUID() 11 | public let uri: String 12 | public let cid: String 13 | public let indexedAt: Date 14 | public let indexAtFormatted: String 15 | public let author: Profile 16 | public let content: String 17 | public let replyCount: Int 18 | public let repostCount: Int 19 | public let likeCount: Int 20 | public let likeURI: String? 21 | public let repostURI: String? 22 | public let embed: ATUnion.EmbedViewUnion? 23 | public let replyRef: AppBskyLexicon.Feed.PostRecord.ReplyReference? 24 | 25 | public var hasReply: Bool = false 26 | 27 | public init( 28 | uri: String, 29 | cid: String, 30 | indexedAt: Date, 31 | author: Profile, 32 | content: String, 33 | replyCount: Int, 34 | repostCount: Int, 35 | likeCount: Int, 36 | likeURI: String?, 37 | repostURI: String?, 38 | embed: ATUnion.EmbedViewUnion?, 39 | replyRef: AppBskyLexicon.Feed.PostRecord.ReplyReference? 40 | ) { 41 | self.uri = uri 42 | self.cid = cid 43 | self.indexedAt = indexedAt 44 | self.author = author 45 | self.content = content 46 | self.replyCount = replyCount 47 | self.repostCount = repostCount 48 | self.likeCount = likeCount 49 | self.likeURI = likeURI 50 | self.repostURI = repostURI 51 | self.embed = embed 52 | self.indexAtFormatted = indexedAt.relativeFormatted 53 | self.replyRef = replyRef 54 | } 55 | } 56 | 57 | extension AppBskyLexicon.Feed.FeedViewPostDefinition { 58 | public var postItem: PostItem { 59 | PostItem( 60 | uri: post.postItem.uri, 61 | cid: post.postItem.cid, 62 | indexedAt: post.indexedAt, 63 | author: .init( 64 | did: post.author.actorDID, 65 | handle: post.author.actorHandle, 66 | displayName: post.author.displayName, 67 | avatarImageURL: post.author.avatarImageURL 68 | ), 69 | content: post.record.getRecord(ofType: AppBskyLexicon.Feed.PostRecord.self)?.text ?? "", 70 | replyCount: post.replyCount ?? 0, 71 | repostCount: post.repostCount ?? 0, 72 | likeCount: post.likeCount ?? 0, 73 | likeURI: post.viewer?.likeURI, 74 | repostURI: post.viewer?.repostURI, 75 | embed: post.embed, 76 | replyRef: post.record.getRecord(ofType: AppBskyLexicon.Feed.PostRecord.self)?.reply 77 | ) 78 | } 79 | } 80 | 81 | extension AppBskyLexicon.Feed.PostViewDefinition { 82 | public var postItem: PostItem { 83 | PostItem( 84 | uri: uri, 85 | cid: cid, 86 | indexedAt: indexedAt, 87 | author: .init( 88 | did: author.actorDID, 89 | handle: author.actorHandle, 90 | displayName: author.displayName, 91 | avatarImageURL: author.avatarImageURL 92 | ), 93 | content: record.getRecord(ofType: AppBskyLexicon.Feed.PostRecord.self)?.text ?? "", 94 | replyCount: replyCount ?? 0, 95 | repostCount: repostCount ?? 0, 96 | likeCount: likeCount ?? 0, 97 | likeURI: viewer?.likeURI, 98 | repostURI: viewer?.repostURI, 99 | embed: embed, 100 | replyRef: record.getRecord(ofType: AppBskyLexicon.Feed.PostRecord.self)?.reply 101 | ) 102 | } 103 | } 104 | 105 | extension AppBskyLexicon.Feed.ThreadViewPostDefinition { 106 | 107 | } 108 | 109 | extension AppBskyLexicon.Embed.RecordDefinition.ViewRecord { 110 | public var postItem: PostItem { 111 | PostItem( 112 | uri: uri, 113 | cid: cid, 114 | indexedAt: indexedAt, 115 | author: .init( 116 | did: author.actorDID, 117 | handle: author.actorHandle, 118 | displayName: author.displayName, 119 | avatarImageURL: author.avatarImageURL 120 | ), 121 | content: value.getRecord(ofType: AppBskyLexicon.Feed.PostRecord.self)?.text ?? "", 122 | replyCount: replyCount ?? 0, 123 | repostCount: repostCount ?? 0, 124 | likeCount: likeCount ?? 0, 125 | likeURI: nil, 126 | repostURI: nil, 127 | embed: embeds?.first, 128 | replyRef: value.getRecord(ofType: AppBskyLexicon.Feed.PostRecord.self)?.reply 129 | ) 130 | } 131 | } 132 | 133 | extension PostItem { 134 | @MainActor public static var placeholders: [PostItem] = Array( 135 | repeating: (), count: 10 136 | ).map { 137 | .init( 138 | uri: UUID().uuidString, 139 | cid: UUID().uuidString, 140 | indexedAt: Date(), 141 | author: .init( 142 | did: "placeholder", 143 | handle: "placeholder@bsky", 144 | displayName: "Placeholder Name", 145 | avatarImageURL: nil), 146 | content: 147 | "Some content some content some content\nSome content some content some content\nsomecontent", 148 | replyCount: 0, 149 | repostCount: 0, 150 | likeCount: 0, 151 | likeURI: nil, 152 | repostURI: nil, 153 | embed: nil, 154 | replyRef: nil) 155 | } 156 | 157 | } 158 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Models/PostsProfileViewFilter.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | 3 | public enum PostsProfileViewFilter: String, Sendable, CaseIterable, Equatable, Hashable { 4 | case postsWithReplies 5 | case postsWithNoReplies 6 | case postsWithMedia 7 | case postAndAuthorThreads 8 | 9 | public var atProtocolFilter: AppBskyLexicon.Feed.GetAuthorFeed.Filter { 10 | switch self { 11 | case .postsWithReplies: return .postsWithReplies 12 | case .postsWithNoReplies: return .postsWithNoReplies 13 | case .postsWithMedia: return .postsWithMedia 14 | case .postAndAuthorThreads: return .postAndAuthorThreads 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Models/Profile.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import Foundation 3 | 4 | public struct Profile: Codable, Hashable { 5 | public let did: String 6 | public let handle: String 7 | public let displayName: String? 8 | public let avatarImageURL: URL? 9 | 10 | public init( 11 | did: String, 12 | handle: String, 13 | displayName: String?, 14 | avatarImageURL: URL? 15 | ) { 16 | self.did = did 17 | self.handle = handle 18 | self.displayName = displayName 19 | self.avatarImageURL = avatarImageURL 20 | } 21 | } 22 | 23 | extension AppBskyLexicon.Actor.ProfileViewDetailedDefinition { 24 | public var profile: Profile { 25 | Profile( 26 | did: actorDID, 27 | handle: actorHandle, 28 | displayName: displayName, 29 | avatarImageURL: avatarImageURL 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Models/RecentFeedItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | @Model 5 | public class RecentFeedItem { 6 | @Attribute(.unique) 7 | public var uri: String 8 | public var name: String 9 | public var avatarImageURL: URL? = nil 10 | public var lastViewedAt: Date 11 | 12 | public init(uri: String, name: String, avatarImageURL: URL?, lastViewedAt: Date) { 13 | self.uri = uri 14 | self.name = name 15 | self.avatarImageURL = avatarImageURL 16 | self.lastViewedAt = lastViewedAt 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Models/Utils/Date.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Date { 4 | public var relativeFormatted: String { 5 | let aDay: TimeInterval = 60 * 60 * 24 6 | let isOlderThanADay = Date().timeIntervalSince(self) >= aDay 7 | if isOlderThanADay { 8 | return DateFormatterCache.shared.createdAtRelativeFormatter.localizedString( 9 | for: self, 10 | relativeTo: Date()) 11 | } else { 12 | return Duration.seconds(-self.timeIntervalSinceNow).formatted( 13 | .units( 14 | width: .narrow, 15 | maximumUnitCount: 1)) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Models/Utils/DateFormatterCache.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class DateFormatterCache: @unchecked Sendable { 4 | static let shared = DateFormatterCache() 5 | 6 | let createdAtRelativeFormatter: RelativeDateTimeFormatter 7 | let createdAtShortDateFormatted: DateFormatter 8 | let createdAtDateFormatter: DateFormatter 9 | 10 | init() { 11 | let createdAtRelativeFormatter = RelativeDateTimeFormatter() 12 | createdAtRelativeFormatter.unitsStyle = .short 13 | createdAtRelativeFormatter.formattingContext = .listItem 14 | createdAtRelativeFormatter.dateTimeStyle = .numeric 15 | self.createdAtRelativeFormatter = createdAtRelativeFormatter 16 | 17 | let createdAtShortDateFormatted = DateFormatter() 18 | createdAtShortDateFormatted.dateStyle = .short 19 | createdAtShortDateFormatted.timeStyle = .none 20 | self.createdAtShortDateFormatted = createdAtShortDateFormatted 21 | 22 | let createdAtDateFormatter = DateFormatter() 23 | createdAtDateFormatter.calendar = .init(identifier: .iso8601) 24 | createdAtDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" 25 | createdAtDateFormatter.timeZone = .init(abbreviation: "UTC") 26 | self.createdAtDateFormatter = createdAtDateFormatter 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Network/BSKyClient.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import SwiftUI 3 | 4 | @Observable 5 | public final class BSkyClient: Sendable { 6 | public let configuration: ATProtocolConfiguration 7 | public let protoClient: ATProtoKit 8 | public let blueskyClient: ATProtoBluesky 9 | 10 | public init(configuration: ATProtocolConfiguration) async { 11 | self.configuration = configuration 12 | self.protoClient = await ATProtoKit(sessionConfiguration: configuration) 13 | self.blueskyClient = ATProtoBluesky(atProtoKitInstance: protoClient) 14 | } 15 | } 16 | 17 | extension ATProtoKit: @unchecked @retroactive Sendable {} 18 | extension ATProtoBluesky: @unchecked @retroactive Sendable {} 19 | -------------------------------------------------------------------------------- /Packages/Model/Sources/User/CurrentUser.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import Network 3 | import SwiftUI 4 | 5 | @Observable 6 | @MainActor 7 | public class CurrentUser { 8 | public let client: BSkyClient 9 | 10 | public private(set) var profile: AppBskyLexicon.Actor.ProfileViewDetailedDefinition? 11 | public private(set) var savedFeeds: [AppBskyLexicon.Actor.SavedFeed] = [] 12 | 13 | public init(client: BSkyClient) async { 14 | self.client = client 15 | await fetch() 16 | } 17 | 18 | public func fetch() async { 19 | await fetchProfile() 20 | await fetchPreferences() 21 | } 22 | 23 | public func fetchProfile() async { 24 | do { 25 | if let DID = try await client.protoClient.getUserSession()?.sessionDID { 26 | self.profile = try await client.protoClient.getProfile(for: DID) 27 | } 28 | } catch { 29 | print(error) 30 | } 31 | } 32 | 33 | public func fetchPreferences() async { 34 | do { 35 | let preferences = try await client.protoClient.getPreferences().preferences 36 | for preference in preferences { 37 | switch preference { 38 | case .savedFeedsVersion2(let feeds): 39 | var feeds = feeds.items 40 | feeds.removeAll(where: { $0.value == "following" }) 41 | self.savedFeeds = feeds 42 | default: 43 | break 44 | } 45 | } 46 | } catch { 47 | print(error) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Packages/Model/Tests/AuthTests/AuthTests.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import Auth 3 | import Testing 4 | 5 | struct AuthTests { 6 | 7 | @Test func testInitialization() { 8 | let auth = Auth() 9 | #expect(auth.configuration == nil) 10 | #expect(auth.authToken == nil) 11 | #expect(auth.refreshToken == nil) 12 | } 13 | 14 | @Test func testKeychainTokenAccess() { 15 | let auth = Auth() 16 | 17 | #expect(auth.authToken == nil) 18 | #expect(auth.refreshToken == nil) 19 | } 20 | 21 | @Test func testSessionLastRefreshedUpdatesOnLogout() async throws { 22 | let auth = Auth() 23 | let initialDate = auth.sessionLastRefreshed 24 | 25 | try await Task.sleep(nanoseconds: 1_000_000) 26 | 27 | try await auth.logout() 28 | 29 | #expect(auth.sessionLastRefreshed > initialDate) 30 | #expect(auth.configuration == nil) 31 | } 32 | 33 | @Test func testLogoutClearsConfiguration() async throws { 34 | let auth = Auth() 35 | 36 | try await auth.logout() 37 | 38 | #expect(auth.configuration == nil) 39 | } 40 | 41 | @Test func testRefreshWithoutTokensReturnsEarly() async throws { 42 | let auth = Auth() 43 | let initialDate = auth.sessionLastRefreshed 44 | 45 | try await Task.sleep(nanoseconds: 1_000_000) 46 | 47 | await auth.refresh() 48 | 49 | #expect(auth.sessionLastRefreshed > initialDate) 50 | #expect(auth.configuration == nil) 51 | } 52 | 53 | @Test func testSessionLastRefreshedUpdatesOnRefresh() async throws { 54 | let auth = Auth() 55 | let initialDate = auth.sessionLastRefreshed 56 | 57 | try await Task.sleep(nanoseconds: 1_000_000) 58 | 59 | await auth.refresh() 60 | 61 | #expect(auth.sessionLastRefreshed > initialDate) 62 | } 63 | 64 | @Test func testAuthInstanceCreation() { 65 | let auth1 = Auth() 66 | let auth2 = Auth() 67 | 68 | #expect(auth1.configuration == nil) 69 | #expect(auth2.configuration == nil) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IcySky 2 | 3 | [![Tuist Preview](https://tuist.dev/Dimillian/IcySky/previews/latest/badge.svg)](https://tuist.dev/Dimillian/IcySky/previews/latest) 4 | 5 | GlowSky, RetroSky, IcySky, and maybe more... 6 | 7 | Very early SwiftUI Bluesky client. 8 | I'm working on the design and essential base layer for now. 9 | Not much to see but you can already login (with an app password) and explore feeds. 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Tuist.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let config = Config( 4 | fullHandle: "Dimillian/IcySky" 5 | ) 6 | -------------------------------------------------------------------------------- /buildServer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xcode build server", 3 | "version": "0.2", 4 | "bspVersion": "2.0", 5 | "languages": [ 6 | "c", 7 | "cpp", 8 | "objective-c", 9 | "objective-cpp", 10 | "swift" 11 | ], 12 | "argv": [ 13 | "/opt/homebrew/bin/xcode-build-server" 14 | ], 15 | "workspace": "/Users/dimillian/Documents/Dev/Other/IcySky/IcySky.xcodeproj/project.xcworkspace", 16 | "build_root": "/Users/dimillian/Library/Developer/Xcode/DerivedData/IcySky-ddbwzwfxthgsenftplwefdqobzne", 17 | "scheme": "IcySky", 18 | "kind": "xcode" 19 | } 20 | -------------------------------------------------------------------------------- /ci_scripts/ci_post_clone.sh: -------------------------------------------------------------------------------- 1 | defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES 2 | --------------------------------------------------------------------------------