├── .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 | [](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 |
--------------------------------------------------------------------------------