├── .mise.toml ├── Images ├── glyph.png ├── image1.png └── image2.png ├── ci_scripts └── ci_post_clone.sh ├── Tuist.swift ├── App ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── glyph.png │ │ ├── icon.png │ │ ├── Frame 11.png │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── IcySky.entitlements ├── AppState.swift ├── AppTabView.swift ├── Destinations │ ├── AppDestinations.swift │ └── SheetDestinations.swift ├── AppTabsRoot.swift ├── Ext │ └── UINavigationControllerExt.swift └── IcySkyApp.swift ├── Packages ├── Features │ ├── Sources │ │ ├── DesignSystem │ │ │ ├── Base │ │ │ │ ├── Colors.xcassets │ │ │ │ │ ├── Contents.json │ │ │ │ │ ├── shadowPrimary.colorset │ │ │ │ │ │ └── Contents.json │ │ │ │ │ └── shadowSecondary.colorset │ │ │ │ │ │ └── Contents.json │ │ │ │ ├── Container.swift │ │ │ │ ├── Colors.swift │ │ │ │ ├── GlowingRoundedRectangle.swift │ │ │ │ └── Gradients.swift │ │ │ ├── Components │ │ │ │ └── CustomSpacingLabel.swift │ │ │ └── Header │ │ │ │ └── HeaderVIew.swift │ │ ├── PostUI │ │ │ ├── List │ │ │ │ ├── Base │ │ │ │ │ ├── PostsListViewDatasource.swift │ │ │ │ │ ├── PostsListViewState.swift │ │ │ │ │ └── PostsListView.swift │ │ │ │ ├── PostsTimelineView.swift │ │ │ │ ├── PostsLikesView.swift │ │ │ │ ├── PostsProfileView.swift │ │ │ │ └── PostsFeedView.swift │ │ │ ├── Row │ │ │ │ ├── PostRowBodyView.swift │ │ │ │ ├── PostRowEmbedQuoteView.swift │ │ │ │ ├── PostRowEmbedView.swift │ │ │ │ ├── PostRowEmbedExternalView.swift │ │ │ │ ├── PostRowActionsView.swift │ │ │ │ ├── PostRowImagesView.swift │ │ │ │ └── PostRowView.swift │ │ │ └── Detail │ │ │ │ └── PostDetailView.swift │ │ ├── ComposerUI │ │ │ ├── ComposerState.swift │ │ │ ├── Views │ │ │ │ ├── ComposerHeaderView.swift │ │ │ │ ├── ComposerSendButtonView.swift │ │ │ │ ├── ComposerTextEditorView.swift │ │ │ │ ├── ComposerToolbarView.swift │ │ │ │ └── ComposerView.swift │ │ │ └── TextProcessing │ │ │ │ ├── ComposerFormattingDefinition.swift │ │ │ │ ├── ComposerTextPattern.swift │ │ │ │ └── ComposerTextProcessor.swift │ │ ├── ProfileUI │ │ │ ├── CurrentUserView.swift │ │ │ └── ProfileView.swift │ │ ├── FeedUI │ │ │ ├── List │ │ │ │ ├── FeedsListFilter.swift │ │ │ │ ├── FeedsListDividerView.swift │ │ │ │ ├── FeedsListErrorView.swift │ │ │ │ ├── FeedsListSearchField.swift │ │ │ │ ├── FeedsListRecentSection.swift │ │ │ │ ├── FeedsListTitleView.swift │ │ │ │ └── FeedsListView.swift │ │ │ └── Row │ │ │ │ ├── TimelineFeedRowView.swift │ │ │ │ ├── FeedCompactRowView.swift │ │ │ │ ├── RecentlyViewedFeedRowView.swift │ │ │ │ └── FeedRowView.swift │ │ ├── SettingsUI │ │ │ └── SettingsView.swift │ │ ├── NotificationsUI │ │ │ ├── Rows │ │ │ │ ├── NotificationIconView.swift │ │ │ │ ├── SingleNotificationRow.swift │ │ │ │ └── GroupedNotificationRow.swift │ │ │ ├── NotificationsListView.swift │ │ │ └── NotificationsGroup.swift │ │ ├── AuthUI │ │ │ └── AuthView.swift │ │ └── MediaUI │ │ │ └── FullScreenMediaView.swift │ ├── .gitignore │ ├── Tests │ │ ├── DesignSystemTests │ │ │ └── HeaderViewTests.swift │ │ └── FeedUITests │ │ │ └── FeedsListTitleViewTests.swift │ ├── .swiftpm │ │ └── xcode │ │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ ├── AuthUI.xcscheme │ │ │ ├── DesignSystem.xcscheme │ │ │ └── FeedUI.xcscheme │ ├── Package.swift │ └── Package.resolved └── Model │ ├── Sources │ ├── Destinations │ │ ├── AppRouter.swift │ │ ├── SheetDestination.swift │ │ ├── RouterDestination.swift │ │ └── AppTab.swift │ ├── Models │ │ ├── PorfileBasicViewExt.swift │ │ ├── Media.swift │ │ ├── RecentFeedItem.swift │ │ ├── Utils │ │ │ ├── Date.swift │ │ │ └── DateFormatterCache.swift │ │ ├── PostsProfileViewFilter.swift │ │ ├── Profile.swift │ │ ├── FeedItem.swift │ │ ├── PostContext.swift │ │ └── PostItem.swift │ ├── Client │ │ └── BSKyClient.swift │ ├── User │ │ └── CurrentUser.swift │ └── Auth │ │ └── Auth.swift │ ├── .gitignore │ ├── Package.swift │ ├── Tests │ └── AuthTests │ │ └── AuthTests.swift │ ├── Package.resolved │ └── .swiftpm │ └── xcode │ └── xcshareddata │ └── xcschemes │ ├── Network.xcscheme │ └── Auth.xcscheme ├── .vscode ├── settings.json └── launch.json ├── IcySky.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── FeaturesTests.xcscheme │ └── IcySky.xcscheme ├── IcySky.code-workspace ├── IcySky-Info.plist ├── FeaturesTests.xctestplan ├── .cursor └── rules │ └── swiftui.mdc ├── .gitignore ├── .github └── workflows │ └── icy_sky.yml ├── README.md ├── TextEditor.md ├── MULTI_ACCOUNT_PLAN.md └── CLAUDE.md /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | tuist = "4.35.0" 3 | -------------------------------------------------------------------------------- /Images/glyph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/IcySky/main/Images/glyph.png -------------------------------------------------------------------------------- /Images/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/IcySky/main/Images/image1.png -------------------------------------------------------------------------------- /Images/image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/IcySky/main/Images/image2.png -------------------------------------------------------------------------------- /ci_scripts/ci_post_clone.sh: -------------------------------------------------------------------------------- 1 | defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES 2 | -------------------------------------------------------------------------------- /Tuist.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let config = Config( 4 | fullHandle: "Dimillian/IcySky" 5 | ) 6 | -------------------------------------------------------------------------------- /App/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/glyph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/IcySky/main/App/Assets.xcassets/AppIcon.appiconset/glyph.png -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/IcySky/main/App/Assets.xcassets/AppIcon.appiconset/icon.png -------------------------------------------------------------------------------- /App/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/Frame 11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/IcySky/main/App/Assets.xcassets/AppIcon.appiconset/Frame 11.png -------------------------------------------------------------------------------- /Packages/Features/Sources/DesignSystem/Base/Colors.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Destinations/AppRouter.swift: -------------------------------------------------------------------------------- 1 | import AppRouter 2 | 3 | public typealias AppRouter = Router 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[swift]": { 3 | "editor.defaultFormatter": "sweetpad.sweetpad", 4 | "editor.formatOnSave": true, 5 | "editor.tabSize": 2 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /IcySky.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /IcySky.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | }, 6 | { 7 | "path": "../../../../Library/Developer/Xcode/DerivedData/IcySky-evrseziujwtgouffdlpdcurwgram/SourcePackages/checkouts/ATProtoKit" 8 | } 9 | ], 10 | "settings": {} 11 | } 12 | -------------------------------------------------------------------------------- /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: Sendable { 5 | case uninitialized 6 | case loading 7 | case loaded(posts: [PostItem], cursor: String?) 8 | case error(Error) 9 | } 10 | -------------------------------------------------------------------------------- /Packages/Features/Sources/ComposerUI/ComposerState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Models 3 | 4 | public enum ComposerMode: Equatable { 5 | case newPost 6 | case reply(PostItem) 7 | } 8 | 9 | public enum ComposerSendState: Equatable { 10 | case idle 11 | case loading 12 | case error(String) 13 | } 14 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /IcySky-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ITSAppUsesNonExemptEncryption 6 | 7 | NSPhotoLibraryAddUsageDescription 8 | Save images to your photo library 9 | 10 | 11 | -------------------------------------------------------------------------------- /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 | } 10 | } 11 | 12 | extension View { 13 | public func screenContainer() -> some View { 14 | modifier(ScreenContainerModifier()) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/Model/Sources/Destinations/SheetDestination.swift: -------------------------------------------------------------------------------- 1 | import AppRouter 2 | import Models 3 | import SwiftUI 4 | 5 | public enum ComposerDestinationMode: Hashable { 6 | case newPost 7 | case reply(PostItem) 8 | } 9 | 10 | public enum SheetDestination: SheetType, Hashable, Identifiable { 11 | public var id: Int { self.hashValue } 12 | 13 | case auth 14 | case fullScreenMedia( 15 | images: [Media], 16 | preloadedImage: URL?, 17 | namespace: Namespace.ID) 18 | case composer(mode: ComposerDestinationMode) 19 | } 20 | -------------------------------------------------------------------------------- /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/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/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(.glass) 21 | } 22 | .listRowSeparator(.hidden) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /App/AppState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Models 3 | import Client 4 | import SwiftUI 5 | import User 6 | 7 | enum AppState: Sendable { 8 | case resuming 9 | case authenticated(client: BSkyClient, currentUser: CurrentUser) 10 | case unauthenticated 11 | case error(Error) 12 | 13 | var client: BSkyClient? { 14 | if case .authenticated(let client, _) = self { 15 | return client 16 | } 17 | return nil 18 | } 19 | 20 | var currentUser: CurrentUser? { 21 | if case .authenticated(_, let currentUser) = self { 22 | return currentUser 23 | } 24 | return nil 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /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/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/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/Features/Tests/DesignSystemTests/HeaderViewTests.swift: -------------------------------------------------------------------------------- 1 | import DesignSystem 2 | import SwiftUI 3 | import Testing 4 | import ViewInspector 5 | 6 | struct HeaderViewTests { 7 | @Test func testHeaderViewTitle() throws { 8 | let title = "TestTitle" 9 | let headerView = HeaderView(title: title, showBack: false) 10 | #expect(try headerView.inspect().find(text: title).string() == title) 11 | } 12 | 13 | @Test func testHeaderViewBackButton() throws { 14 | let title = "TestTitle" 15 | let headerView = HeaderView(title: title, showBack: true) 16 | #expect(try headerView.inspect().find(viewWithId: "back").image().font() == .title) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /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(.glass) 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Client/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/Features/Tests/FeedUITests/FeedsListTitleViewTests.swift: -------------------------------------------------------------------------------- 1 | import FeedUI 2 | import SwiftUI 3 | import Testing 4 | import ViewInspector 5 | 6 | struct FeedsListTitleViewTests { 7 | @FocusState var isSearchFocused: Bool 8 | @State var filter: FeedsListFilter = .myFeeds 9 | 10 | @Test func testFeedTitleViewBase() throws { 11 | let view = FeedsListTitleView( 12 | filter: $filter, 13 | searchText: .constant(""), 14 | isInSearch: .constant(false), 15 | isSearchFocused: $isSearchFocused) 16 | #expect(try view.inspect().find(text: "Feeds").string() == "Feeds") 17 | #expect( 18 | try view.inspect().find(text: filter.rawValue).string() == FeedsListFilter.myFeeds.rawValue) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Packages/Features/Sources/ComposerUI/Views/ComposerHeaderView.swift: -------------------------------------------------------------------------------- 1 | import DesignSystem 2 | import Models 3 | import SwiftUI 4 | 5 | struct ComposerHeaderView: ToolbarContent { 6 | @Environment(\.dismiss) var dismiss 7 | @Binding var sendState: ComposerSendState 8 | let onSend: () async -> Void 9 | 10 | var body: some ToolbarContent { 11 | ToolbarItem(placement: .topBarTrailing) { 12 | ComposerSendButtonView( 13 | sendState: sendState, 14 | onSend: onSend 15 | ) 16 | } 17 | ToolbarItem(placement: .topBarLeading) { 18 | Button { 19 | dismiss() 20 | } label: { 21 | Image(systemName: "xmark") 22 | .foregroundStyle(.redPurple) 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /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/Model/Sources/Models/Profile.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import Foundation 3 | 4 | public struct Profile: Codable, Hashable, Sendable { 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/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, profile, settings, compose 10 | 11 | public var id: String { rawValue } 12 | 13 | public var title: String { 14 | switch self { 15 | case .feed: return "Feed" 16 | case .notification: return "Notifications" 17 | case .profile: return "Profile" 18 | case .settings: return "Settings" 19 | case .compose: return "New Post" 20 | } 21 | } 22 | 23 | public var icon: String { 24 | switch self { 25 | case .feed: return "square.stack" 26 | case .notification: return "bell" 27 | case .profile: return "person" 28 | case .settings: return "gearshape" 29 | case .compose: return "square.and.pencil" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /FeaturesTests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "79418425-9BDC-4B83-8F74-854A9D664E7A", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "codeCoverage" : false, 13 | "targetForVariableExpansion" : { 14 | "containerPath" : "container:IcySky.xcodeproj", 15 | "identifier" : "9F2142222CE9DAA1004167D7", 16 | "name" : "IcySky" 17 | } 18 | }, 19 | "testTargets" : [ 20 | { 21 | "target" : { 22 | "containerPath" : "container:Packages\/Features", 23 | "identifier" : "FeedUITests", 24 | "name" : "FeedUITests" 25 | } 26 | }, 27 | { 28 | "target" : { 29 | "containerPath" : "container:Packages\/Features", 30 | "identifier" : "DesignSystemTests", 31 | "name" : "DesignSystemTests" 32 | } 33 | } 34 | ], 35 | "version" : 1 36 | } 37 | -------------------------------------------------------------------------------- /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/FeedUI/Row/TimelineFeedRowView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Destinations 5 | import Models 6 | import Client 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 | -------------------------------------------------------------------------------- /App/AppTabView.swift: -------------------------------------------------------------------------------- 1 | import DesignSystem 2 | import Destinations 3 | import SwiftUI 4 | import AppRouter 5 | 6 | struct AppTabView: View { 7 | @Environment(AppRouter.self) var router 8 | let tabs: [AppTab] = AppTab.allCases 9 | 10 | var body: some View { 11 | @Bindable var router = router 12 | TabView(selection: $router.selectedTab) { 13 | ForEach(AppTab.allCases) { tab in 14 | Tab(value: tab, role: tab == .compose ? .search : .none) { 15 | AppTabRootView(tab: tab) 16 | } label: { 17 | Label(tab.title, systemImage: tab.icon) 18 | } 19 | } 20 | } 21 | .tint(.indigo) 22 | .tabBarMinimizeBehavior(.onScrollDown) 23 | .onChange(of: router.selectedTab, { oldTab, newTab in 24 | if newTab == .compose { 25 | router.presentedSheet = .composer(mode: .newPost) 26 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { 27 | router.selectedTab = oldTab 28 | } 29 | } 30 | }) 31 | } 32 | } 33 | 34 | #Preview { 35 | AppTabView() 36 | .environment(AppRouter(initialTab: .feed)) 37 | } 38 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /App/Destinations/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 | -------------------------------------------------------------------------------- /Packages/Features/Sources/ComposerUI/Views/ComposerSendButtonView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ComposerSendButtonView: View { 4 | let sendState: ComposerSendState 5 | let onSend: () async -> Void 6 | @State private var showError = false 7 | 8 | var body: some View { 9 | Button { 10 | switch sendState { 11 | case .error: 12 | showError = true 13 | case .idle: 14 | Task { await onSend() } 15 | case .loading: 16 | break 17 | } 18 | } label: { 19 | switch sendState { 20 | case .loading: 21 | ProgressView() 22 | case .error: 23 | Image(systemName: "exclamationmark.triangle") 24 | .foregroundStyle(.red) 25 | default: 26 | Image(systemName: "paperplane") 27 | .foregroundStyle(.indigoPurple) 28 | } 29 | } 30 | .alert("Error", isPresented: $showError) { 31 | Button("OK", role: .cancel) { 32 | showError = false 33 | } 34 | } message: { 35 | if case .error(let errorMessage) = sendState { 36 | Text(errorMessage) 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /App/AppTabsRoot.swift: -------------------------------------------------------------------------------- 1 | import AppRouter 2 | import Auth 3 | import DesignSystem 4 | import Destinations 5 | import FeedUI 6 | import Models 7 | import Client 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 | extension AppTab { 35 | @ViewBuilder 36 | fileprivate var rootView: some View { 37 | switch self { 38 | case .feed: 39 | FeedsListView() 40 | case .profile: 41 | CurrentUserView() 42 | case .notification: 43 | NotificationsListView() 44 | case .settings: 45 | SettingsView() 46 | case .compose: 47 | EmptyView() 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /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/List/PostsTimelineView.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import DesignSystem 3 | import Models 4 | import Client 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: @MainActor 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/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/Features/Sources/PostUI/List/PostsLikesView.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import DesignSystem 3 | import Models 4 | import Client 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: @MainActor 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/Features/Sources/ComposerUI/TextProcessing/ComposerFormattingDefinition.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// The formatting definition for composer text 4 | struct ComposerFormattingDefinition: AttributedTextFormattingDefinition { 5 | typealias Scope = AttributeScopes.ComposerAttributes 6 | 7 | var body: some AttributedTextFormattingDefinition { 8 | PatternColorConstraint() 9 | URLUnderlineConstraint() 10 | } 11 | } 12 | 13 | /// Constraint that applies colors based on text patterns 14 | struct PatternColorConstraint: AttributedTextValueConstraint { 15 | typealias Scope = AttributeScopes.ComposerAttributes 16 | typealias AttributeKey = AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute 17 | 18 | func constrain(_ container: inout Attributes) { 19 | if let pattern = container.textPattern { 20 | container.foregroundColor = pattern.color 21 | } else { 22 | container.foregroundColor = nil 23 | } 24 | } 25 | } 26 | 27 | /// Constraint that underlines URLs 28 | struct URLUnderlineConstraint: AttributedTextValueConstraint { 29 | typealias Scope = AttributeScopes.ComposerAttributes 30 | typealias AttributeKey = AttributeScopes.SwiftUIAttributes.UnderlineStyleAttribute 31 | 32 | func constrain(_ container: inout Attributes) { 33 | if container.textPattern == .url { 34 | container.underlineStyle = .single 35 | } else { 36 | container.underlineStyle = nil 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /Packages/Features/Sources/FeedUI/List/FeedsListSearchField.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import DesignSystem 3 | 4 | public struct FeedsListSearchField: View { 5 | 6 | @Binding var searchText: String 7 | @Binding var isInSearch: Bool 8 | var isSearchFocused: FocusState.Binding 9 | 10 | public init( 11 | searchText: Binding, 12 | isInSearch: Binding, 13 | isSearchFocused: FocusState.Binding 14 | ) { 15 | _searchText = searchText 16 | _isInSearch = isInSearch 17 | self.isSearchFocused = isSearchFocused 18 | } 19 | 20 | public var body: some View { 21 | GlassEffectContainer { 22 | HStack { 23 | HStack { 24 | Image(systemName: "magnifyingglass") 25 | TextField("Search", text: $searchText) 26 | .focused(isSearchFocused) 27 | .allowsHitTesting(isInSearch) 28 | } 29 | .frame(maxWidth: isInSearch ? .infinity : 100) 30 | .padding() 31 | .glassEffect(in: Capsule()) 32 | 33 | if isInSearch { 34 | Button { 35 | withAnimation { 36 | isInSearch.toggle() 37 | isSearchFocused.wrappedValue = false 38 | searchText = "" 39 | } 40 | } label: { 41 | Image(systemName: "xmark") 42 | .frame(width: 50, height: 50) 43 | .foregroundStyle(.redPurple) 44 | .glassEffect(in: Circle()) 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Packages/Features/Sources/FeedUI/Row/FeedCompactRowView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Destinations 5 | import Models 6 | import Client 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/PostUI/List/PostsProfileView.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import DesignSystem 3 | import Models 4 | import Client 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: @MainActor 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/ComposerUI/Views/ComposerTextEditorView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ComposerTextEditorView: View { 4 | @Binding var text: AttributedString 5 | @Binding var selection: AttributedTextSelection 6 | 7 | let sendState: ComposerSendState 8 | @FocusState private var isFocused: Bool 9 | 10 | @State private var isPlaceholder = true 11 | @State private var processor = ComposerTextProcessor() 12 | 13 | var body: some View { 14 | ZStack(alignment: .topLeading) { 15 | TextEditor(text: $text, selection: $selection) 16 | .textInputFormattingControlVisibility(.hidden, for: .all) 17 | .font(.system(size: UIFontMetrics.default.scaledValue(for: 20))) 18 | .frame(maxWidth: .infinity) 19 | .padding() 20 | .focused($isFocused) 21 | .textEditorStyle(.plain) 22 | .disabled(sendState == .loading) 23 | .attributedTextFormattingDefinition(ComposerFormattingDefinition()) 24 | .onAppear { 25 | isFocused = true 26 | } 27 | .onChange(of: text, initial: true) { oldValue, newValue in 28 | isPlaceholder = newValue.characters.isEmpty 29 | 30 | processor.processText(&text) 31 | } 32 | 33 | if isPlaceholder { 34 | Text("What's on your mind?") 35 | .font(.system(size: UIFontMetrics.default.scaledValue(for: 20))) 36 | .foregroundStyle(.secondary) 37 | .padding() 38 | .padding(.top, 6) 39 | .padding(.leading, 8) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Packages/Model/Sources/User/CurrentUser.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import Client 3 | import SwiftUI 4 | 5 | @Observable 6 | public final class CurrentUser: @unchecked Sendable { 7 | public let client: BSkyClient 8 | 9 | public private(set) var profile: AppBskyLexicon.Actor.ProfileViewDetailedDefinition? 10 | public private(set) var savedFeeds: [AppBskyLexicon.Actor.SavedFeed] = [] 11 | 12 | public init(client: BSkyClient) async throws { 13 | self.client = client 14 | try await refresh() 15 | } 16 | 17 | public func refresh() async throws { 18 | async let profile: AppBskyLexicon.Actor.ProfileViewDetailedDefinition? = fetchProfile() 19 | async let savedFeeds = fetchPreferences() 20 | (self.profile, self.savedFeeds) = try await (profile, savedFeeds) 21 | } 22 | 23 | public func fetchProfile() async throws -> AppBskyLexicon.Actor.ProfileViewDetailedDefinition? { 24 | if let DID = try await client.protoClient.getUserSession()?.sessionDID { 25 | return try await client.protoClient.getProfile(for: DID) 26 | } 27 | return nil 28 | } 29 | 30 | public func fetchPreferences() async throws -> [AppBskyLexicon.Actor.SavedFeed] { 31 | let preferences = try await client.protoClient.getPreferences().preferences 32 | for preference in preferences { 33 | switch preference { 34 | case .savedFeedsVersion2(let feeds): 35 | var feeds = feeds.items 36 | feeds.removeAll(where: { $0.value == "following" }) 37 | return feeds 38 | default: 39 | return [] 40 | } 41 | } 42 | return [] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /App/Ext/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 | -------------------------------------------------------------------------------- /Packages/Features/Sources/DesignSystem/Base/Gradients.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension LinearGradient { 4 | static let indigoPurple = LinearGradient( 5 | colors: [.indigo, .purple], 6 | startPoint: .top, 7 | endPoint: .bottom 8 | ) 9 | 10 | static let purpleIndigo = LinearGradient( 11 | colors: [.purple, .indigo], 12 | startPoint: .top, 13 | endPoint: .bottom 14 | ) 15 | 16 | static let redPurple = LinearGradient( 17 | colors: [.red, .purple], 18 | startPoint: .topLeading, 19 | endPoint: .bottomTrailing 20 | ) 21 | 22 | static let indigoPurpleHorizontal = LinearGradient( 23 | colors: [.indigo, .purple], 24 | startPoint: .leading, 25 | endPoint: .trailing 26 | ) 27 | 28 | static let indigoPurpleAvatar = LinearGradient( 29 | colors: [.purple, .indigo], 30 | startPoint: .topLeading, 31 | endPoint: .bottomTrailing 32 | ) 33 | 34 | static func avatarBorder(hasReply: Bool) -> LinearGradient { 35 | LinearGradient( 36 | colors: hasReply 37 | ? [.purple, .indigo] 38 | : [.shadowPrimary.opacity(0.5), .indigo.opacity(0.5)], 39 | startPoint: .topLeading, 40 | endPoint: .bottomTrailing 41 | ) 42 | } 43 | } 44 | 45 | public extension ShapeStyle where Self == LinearGradient { 46 | static var indigoPurple: LinearGradient { .indigoPurple } 47 | static var purpleIndigo: LinearGradient { .purpleIndigo } 48 | static var redPurple: LinearGradient { .redPurple } 49 | static var indigoPurpleHorizontal: LinearGradient { .indigoPurpleHorizontal } 50 | static var indigoPurpleAvatar: LinearGradient { .indigoPurpleAvatar } 51 | } -------------------------------------------------------------------------------- /Packages/Features/Sources/AuthUI/AuthView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import Auth 3 | import DesignSystem 4 | import Models 5 | import Client 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(.glass) 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/ComposerUI/Views/ComposerToolbarView.swift: -------------------------------------------------------------------------------- 1 | import DesignSystem 2 | import Client 3 | import SwiftUI 4 | 5 | struct ComposerToolbarView: ToolbarContent { 6 | @Binding var text: AttributedString 7 | @Binding var sendState: ComposerSendState 8 | 9 | var body: some ToolbarContent { 10 | ToolbarItem(placement: .keyboard) { 11 | Button { 12 | } label: { 13 | Image(systemName: "photo") 14 | .foregroundStyle(.indigoPurple) 15 | } 16 | 17 | } 18 | 19 | ToolbarItem(placement: .keyboard) { 20 | Button { 21 | } label: { 22 | Image(systemName: "film") 23 | .foregroundStyle(.indigoPurple) 24 | } 25 | } 26 | 27 | ToolbarItem(placement: .keyboard) { 28 | Button { 29 | } label: { 30 | Image(systemName: "camera") 31 | .foregroundStyle(.indigoPurple) 32 | } 33 | } 34 | 35 | ToolbarSpacer(placement: .keyboard) 36 | 37 | ToolbarItem(placement: .keyboard) { 38 | Button { 39 | } label: { 40 | Image(systemName: "at") 41 | .foregroundStyle(.indigoPurple) 42 | } 43 | } 44 | 45 | ToolbarItem(placement: .keyboard) { 46 | Button { 47 | } label: { 48 | Image(systemName: "tag") 49 | .foregroundStyle(.indigoPurple) 50 | } 51 | } 52 | 53 | ToolbarItem(placement: .keyboard) { 54 | let text = String(text.characters) 55 | Text("\(300 - text.count)") 56 | .foregroundStyle(text.count > 250 ? .redPurple : .indigoPurple) 57 | .font(.subheadline) 58 | .contentTransition(.numericText(value: Double(text.count))) 59 | .monospacedDigit() 60 | .lineLimit(1) 61 | .animation(.smooth, value: text.count) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /App/Destinations/SheetDestinations.swift: -------------------------------------------------------------------------------- 1 | import AppRouter 2 | import Auth 3 | import AuthUI 4 | import ComposerUI 5 | import Destinations 6 | import MediaUI 7 | import Client 8 | import SwiftUI 9 | import User 10 | 11 | public struct SheetDestinations: ViewModifier { 12 | @Binding var router: AppRouter 13 | let auth: Auth 14 | let client: BSkyClient? 15 | let currentUser: CurrentUser? 16 | 17 | public func body(content: Content) -> some View { 18 | content 19 | .sheet(item: $router.presentedSheet) { presentedSheet in 20 | switch presentedSheet { 21 | case .auth: 22 | AuthView() 23 | .environment(auth) 24 | case let .fullScreenMedia(images, preloadedImage, namespace): 25 | FullScreenMediaView( 26 | images: images, 27 | preloadedImage: preloadedImage, 28 | namespace: namespace 29 | ) 30 | case let .composer(mode): 31 | if let client, let currentUser { 32 | switch mode { 33 | case .newPost: 34 | ComposerView(mode: .newPost) 35 | .environment(client) 36 | .environment(currentUser) 37 | case let .reply(post): 38 | ComposerView(mode: .reply(post)) 39 | .environment(client) 40 | .environment(currentUser) 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | extension View { 49 | public func withSheetDestinations( 50 | router: Binding, 51 | auth: Auth, 52 | client: BSkyClient? = nil, 53 | currentUser: CurrentUser? = nil 54 | ) -> some View { 55 | modifier( 56 | SheetDestinations( 57 | router: router, 58 | auth: auth, 59 | client: client, 60 | currentUser: currentUser 61 | )) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Packages/Model/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.2 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(.v26), .macOS(.v26)], 9 | products: [ 10 | .library(name: "Client", targets: ["Client"]), 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(url: "https://github.com/MasterJ93/ATProtoKit", from: "0.28.0"), 18 | .package(url: "https://github.com/evgenyneu/keychain-swift", from: "24.0.0"), 19 | .package(url: "https://github.com/Dimillian/AppRouter.git", from: "1.0.2"), 20 | ], 21 | targets: [ 22 | .target( 23 | name: "Client", 24 | dependencies: [ 25 | .product(name: "ATProtoKit", package: "ATProtoKit") 26 | ] 27 | ), 28 | .target( 29 | name: "Models", 30 | dependencies: [ 31 | .product(name: "ATProtoKit", package: "ATProtoKit"), 32 | "Client", 33 | ] 34 | ), 35 | .target( 36 | name: "Auth", 37 | dependencies: [ 38 | .product(name: "ATProtoKit", package: "ATProtoKit"), 39 | .product(name: "KeychainSwift", package: "keychain-swift"), 40 | ] 41 | ), 42 | .testTarget( 43 | name: "AuthTests", 44 | dependencies: ["Auth"] 45 | ), 46 | .target( 47 | name: "User", 48 | dependencies: [ 49 | .product(name: "ATProtoKit", package: "ATProtoKit"), 50 | "Client", 51 | ] 52 | ), 53 | .target( 54 | name: "Destinations", 55 | dependencies: ["Models", "AppRouter"] 56 | ), 57 | ] 58 | ) 59 | -------------------------------------------------------------------------------- /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/ComposerUI/TextProcessing/ComposerTextPattern.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Foundation 3 | 4 | // Custom attribute keys for text patterns 5 | struct TextPatternAttribute: CodableAttributedStringKey { 6 | typealias Value = ComposerTextPattern 7 | 8 | static let name = "IcySky.TextPatternAttribute" 9 | static let inheritedByAddedText: Bool = false 10 | static let invalidationConditions: Set? = [.textChanged] 11 | } 12 | 13 | extension AttributeScopes { 14 | struct ComposerAttributes: AttributeScope { 15 | let textPattern: TextPatternAttribute 16 | let foregroundColor: AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute 17 | let underlineStyle: AttributeScopes.SwiftUIAttributes.UnderlineStyleAttribute 18 | } 19 | } 20 | 21 | extension AttributeDynamicLookup { 22 | subscript( 23 | dynamicMember keyPath: KeyPath 24 | ) -> T { 25 | self[T.self] 26 | } 27 | } 28 | 29 | enum ComposerTextPattern: String, CaseIterable, Codable { 30 | case hashtag 31 | case mention 32 | case url 33 | 34 | var pattern: String { 35 | switch self { 36 | case .hashtag: 37 | return "#\\w+" 38 | case .mention: 39 | return "@[\\w.-]+" 40 | case .url: 41 | return "(?i)https?://(?:www\\.)?\\S+(?:/|\\b)" 42 | } 43 | } 44 | 45 | var color: Color { 46 | switch self { 47 | case .hashtag: 48 | return .purple 49 | case .mention: 50 | return .indigo 51 | case .url: 52 | return .blue 53 | } 54 | } 55 | 56 | func matches(_ text: String) -> Bool { 57 | switch self { 58 | case .hashtag: 59 | return text.hasPrefix("#") 60 | case .mention: 61 | return text.hasPrefix("@") 62 | case .url: 63 | return text.lowercased().hasPrefix("http") 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/List/PostsFeedView.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import DesignSystem 3 | import Models 4 | import Client 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: @MainActor 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/FeedUI/Row/RecentlyViewedFeedRowView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Destinations 5 | import Models 6 | import Client 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 | .listRowInsets(.vertical, 0) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Models/PostContext.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import Foundation 3 | import Client 4 | import SwiftUI 5 | 6 | @MainActor 7 | @Observable 8 | public class PostContextProvider { 9 | @ObservationIgnored 10 | private var contexts: [String: PostContext] = [:] 11 | 12 | public init() {} 13 | 14 | public func get(for post: PostItem, client: BSkyClient) -> PostContext { 15 | if let context = contexts[post.uri] { 16 | return context 17 | } else { 18 | let context = PostContext(post: post, client: client) 19 | contexts[post.uri] = context 20 | return context 21 | } 22 | } 23 | } 24 | 25 | @MainActor 26 | @Observable 27 | public final class PostContext: Sendable { 28 | private var post: PostItem 29 | private let client: BSkyClient 30 | 31 | public var isLiked: Bool { likeURI != nil } 32 | public var isReposted: Bool { repostURI != nil } 33 | 34 | public var likeCount: Int { post.likeCount + (isLiked ? 1 : 0) } 35 | public var repostCount: Int { post.repostCount + (isReposted ? 1 : 0) } 36 | 37 | private var likeURI: String? 38 | private var repostURI: String? 39 | 40 | public init(post: PostItem, client: BSkyClient) { 41 | self.post = post 42 | self.client = client 43 | 44 | likeURI = post.likeURI 45 | repostURI = post.repostURI 46 | } 47 | 48 | public func update(with post: PostItem) { 49 | self.post = post 50 | 51 | likeURI = post.likeURI 52 | repostURI = post.repostURI 53 | } 54 | 55 | public func toggleLike() async { 56 | let previousState = likeURI 57 | do { 58 | if let likeURI { 59 | self.likeURI = nil 60 | try await client.blueskyClient.deleteRecord(.recordURI(atURI: likeURI)) 61 | } else { 62 | self.likeURI = "ui.optimistic.like" 63 | self.likeURI = try await client.blueskyClient.createLikeRecord( 64 | .init(recordURI: post.uri, cidHash: post.cid) 65 | ).recordURI 66 | } 67 | } catch { 68 | self.likeURI = previousState 69 | } 70 | } 71 | 72 | public func toggleRepost() async { 73 | // TODO: Implement 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Packages/Features/Sources/ComposerUI/TextProcessing/ComposerTextProcessor.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Foundation 3 | 4 | /// Processes text to identify and mark patterns (hashtags, mentions, URLs) 5 | struct ComposerTextProcessor { 6 | private let combinedRegex: Regex 7 | 8 | init() { 9 | // Build regex from the patterns defined in ComposerTextPattern 10 | let patterns = ComposerTextPattern.allCases.map { $0.pattern } 11 | // Add patterns for in-progress typing 12 | let inProgressPatterns = ["#$", "@$"] // Just # or @ being typed 13 | 14 | let combinedPattern = (patterns + inProgressPatterns).joined(separator: "|") 15 | self.combinedRegex = try! Regex(combinedPattern) 16 | } 17 | 18 | /// Process text to apply pattern attributes 19 | func processText(_ text: inout AttributedString) { 20 | // Create a completely fresh AttributedString from the plain text 21 | // This ensures we don't inherit any fragmented runs from TextEditor 22 | let plainString = String(text.characters) 23 | var freshText = AttributedString(plainString) 24 | 25 | // Find and apply all pattern matches 26 | for match in plainString.matches(of: combinedRegex) { 27 | let matchedText = String(plainString[match.range]) 28 | 29 | // Skip empty matches 30 | guard !matchedText.isEmpty else { continue } 31 | 32 | // Determine which pattern type this is 33 | guard let pattern = ComposerTextPattern.allCases.first(where: { $0.matches(matchedText) }) else { 34 | continue 35 | } 36 | 37 | // Convert String range to AttributedString indices 38 | guard let matchStart = AttributedString.Index(match.range.lowerBound, within: freshText), 39 | let matchEnd = AttributedString.Index(match.range.upperBound, within: freshText) else { 40 | continue 41 | } 42 | 43 | // Apply the pattern attribute to the fresh text 44 | freshText[matchStart..= 2 { break } 64 | } 65 | } 66 | 67 | // Simulate logout (emits nil) 68 | try await auth.logout() 69 | 70 | // Simulate refresh failure (emits nil) 71 | await auth.refresh() 72 | 73 | try await Task.sleep(nanoseconds: 100_000_000) // 100ms 74 | task.cancel() 75 | 76 | #expect(updates.count == 2) 77 | #expect(updates.allSatisfy { $0 == nil }) 78 | } 79 | } -------------------------------------------------------------------------------- /Packages/Features/Sources/FeedUI/List/FeedsListTitleView.swift: -------------------------------------------------------------------------------- 1 | import DesignSystem 2 | import Client 3 | import SwiftUI 4 | 5 | public struct FeedsListTitleView: View { 6 | @Binding var filter: FeedsListFilter 7 | @Binding var searchText: String 8 | @Binding var isInSearch: Bool 9 | var isSearchFocused: FocusState.Binding 10 | 11 | public init( 12 | filter: Binding, 13 | searchText: Binding, 14 | isInSearch: Binding, 15 | isSearchFocused: FocusState.Binding 16 | ) { 17 | self._filter = filter 18 | self._searchText = searchText 19 | self._isInSearch = isInSearch 20 | self.isSearchFocused = isSearchFocused 21 | } 22 | 23 | public var body: some View { 24 | HStack(alignment: .center) { 25 | Menu { 26 | ForEach(FeedsListFilter.allCases) { filter in 27 | Button(action: { 28 | self.filter = filter 29 | }) { 30 | Label(filter.rawValue, systemImage: filter.icon) 31 | } 32 | } 33 | } label: { 34 | HStack { 35 | VStack(alignment: .leading, spacing: 2) { 36 | Text("Feeds") 37 | .headerTitleShadow() 38 | .font(.title) 39 | .fontWeight(.bold) 40 | Text(filter.rawValue) 41 | .font(.subheadline) 42 | .foregroundStyle(.secondary) 43 | } 44 | VStack(spacing: 6) { 45 | Image(systemName: "chevron.up") 46 | Image(systemName: "chevron.down") 47 | } 48 | .imageScale(.large) 49 | .offset(y: 2) 50 | } 51 | } 52 | .buttonStyle(.plain) 53 | .offset(x: isInSearch ? -200 : 0) 54 | .opacity(isInSearch ? 0 : 1) 55 | 56 | Spacer() 57 | 58 | FeedsListSearchField( 59 | searchText: $searchText, 60 | isInSearch: $isInSearch, 61 | isSearchFocused: isSearchFocused 62 | ) 63 | .padding(.leading, isInSearch ? -120 : 0) 64 | .contentShape(Rectangle()) 65 | .onTapGesture { 66 | withAnimation(.bouncy) { 67 | isInSearch.toggle() 68 | isSearchFocused.wrappedValue = true 69 | } 70 | } 71 | .transition(.slide) 72 | } 73 | .animation(.bouncy, value: isInSearch) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Packages/Model/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "f5998f1fd4b3a5baa386c7cf6177b70540452c183d1bd30c8e3fb613aaaed138", 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/MasterJ93/ATProtoKit", 17 | "state" : { 18 | "revision" : "9d4b9f4bb61fba8e2b1303ef232a860e6b1652b0", 19 | "version" : "0.28.0" 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/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 configuration: ATProtocolConfiguration? 11 | 12 | private let configurationContinuation: AsyncStream.Continuation 13 | public let configurationUpdates: AsyncStream 14 | 15 | private let ATProtoKeychain: AppleSecureKeychain 16 | 17 | public func logout() async throws { 18 | try await configuration?.deleteSession() 19 | configuration = nil 20 | configurationContinuation.yield(nil) 21 | } 22 | 23 | public init() { 24 | if let uuid = keychain.get("session_uuid") { 25 | self.ATProtoKeychain = AppleSecureKeychain(identifier: .init(uuidString: uuid) ?? UUID()) 26 | } else { 27 | let newUUID = UUID().uuidString 28 | keychain.set(newUUID, forKey: "session_uuid") 29 | self.ATProtoKeychain = AppleSecureKeychain(identifier: .init(uuidString: newUUID) ?? UUID()) 30 | } 31 | 32 | var continuation: AsyncStream.Continuation! 33 | self.configurationUpdates = AsyncStream { cont in 34 | continuation = cont 35 | } 36 | self.configurationContinuation = continuation 37 | } 38 | 39 | public func authenticate(handle: String, appPassword: String) async throws { 40 | let configuration = ATProtocolConfiguration(keychainProtocol: ATProtoKeychain) 41 | try await configuration.authenticate(with: handle, password: appPassword) 42 | self.configuration = configuration 43 | configurationContinuation.yield(configuration) 44 | } 45 | 46 | public func refresh() async { 47 | do { 48 | let configuration = ATProtocolConfiguration(keychainProtocol: ATProtoKeychain) 49 | try await configuration.refreshSession() 50 | self.configuration = configuration 51 | configurationContinuation.yield(configuration) 52 | } catch { 53 | self.configuration = nil 54 | configurationContinuation.yield(nil) 55 | } 56 | } 57 | 58 | } 59 | 60 | extension UserSession: @retroactive Equatable { 61 | public static func == (lhs: UserSession, rhs: UserSession) -> Bool { 62 | lhs.sessionDID == rhs.sessionDID 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Packages/Features/Sources/NotificationsUI/Rows/SingleNotificationRow.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import Destinations 4 | import Models 5 | import Client 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/Row/PostRowActionsView.swift: -------------------------------------------------------------------------------- 1 | import AppRouter 2 | import DesignSystem 3 | import Destinations 4 | import Models 5 | import Client 6 | import SwiftUI 7 | 8 | extension EnvironmentValues { 9 | @Entry public var hideMoreActions = false 10 | } 11 | 12 | public struct PostRowActionsView: View { 13 | @Environment(\.hideMoreActions) var hideMoreActions 14 | @Environment(PostContext.self) var dataController 15 | @Environment(AppRouter.self) var router 16 | 17 | let post: PostItem 18 | 19 | public init(post: PostItem) { 20 | self.post = post 21 | } 22 | 23 | public var body: some View { 24 | HStack(alignment: .firstTextBaseline, spacing: 16) { 25 | Button(action: { 26 | router.presentedSheet = .composer(mode: .reply(post)) 27 | }) { 28 | Label("\(post.replyCount)", systemImage: "bubble") 29 | } 30 | .buttonStyle(.plain) 31 | .foregroundStyle(.indigoPurple) 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(.purpleIndigo) 43 | 44 | Button(action: { 45 | Task { 46 | await dataController.toggleLike() 47 | } 48 | }) { 49 | Label("\(dataController.likeCount)", systemImage: "heart") 50 | .lineLimit(1) 51 | } 52 | .buttonStyle(.plain) 53 | .symbolVariant(dataController.isLiked ? .fill : .none) 54 | .symbolEffect(.bounce, value: dataController.isLiked) 55 | .contentTransition(.numericText(value: Double(dataController.likeCount))) 56 | .monospacedDigit() 57 | .animation(.smooth, value: dataController.likeCount) 58 | .foregroundStyle(.redPurple) 59 | 60 | Spacer() 61 | 62 | if !hideMoreActions { 63 | Button(action: {}) { 64 | Image(systemName: "ellipsis") 65 | } 66 | .buttonStyle(.plain) 67 | .foregroundStyle(.indigoPurpleHorizontal) 68 | } 69 | } 70 | .buttonStyle(.plain) 71 | .labelStyle(.customSpacing(4)) 72 | .font(.callout) 73 | .padding(.top, 8) 74 | .padding(.bottom, 16) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /App/IcySkyApp.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import Auth 4 | import DesignSystem 5 | import Destinations 6 | import Models 7 | import Client 8 | import Nuke 9 | import NukeUI 10 | import SwiftUI 11 | import User 12 | 13 | @main 14 | struct IcySkyApp: App { 15 | @Environment(\.scenePhase) var scenePhase 16 | 17 | @State var appState: AppState = .resuming 18 | @State var auth: Auth = .init() 19 | @State var router: AppRouter = .init(initialTab: .feed) 20 | @State var postDataControllerProvider: PostContextProvider = .init() 21 | 22 | init() { 23 | ImagePipeline.shared = ImagePipeline(configuration: .withDataCache) 24 | } 25 | 26 | var body: some Scene { 27 | WindowGroup { 28 | Group { 29 | switch appState { 30 | case .resuming: 31 | ProgressView() 32 | .containerRelativeFrame([.horizontal, .vertical]) 33 | case .authenticated(let client, let currentUser): 34 | AppTabView() 35 | .environment(client) 36 | .environment(currentUser) 37 | .environment(auth) 38 | .environment(router) 39 | .environment(postDataControllerProvider) 40 | case .unauthenticated: 41 | Text("Unauthenticated") 42 | case .error(let error): 43 | Text("Error: \(error.localizedDescription)") 44 | } 45 | } 46 | .modelContainer(for: RecentFeedItem.self) 47 | .withSheetDestinations( 48 | router: $router, 49 | auth: auth, 50 | client: appState.client, 51 | currentUser: appState.currentUser 52 | ) 53 | .task(id: scenePhase) { 54 | if scenePhase == .active { 55 | await auth.refresh() 56 | } 57 | } 58 | .task { 59 | for await configuration in auth.configurationUpdates { 60 | if let configuration { 61 | router.presentedSheet = nil 62 | await refreshEnvWith(configuration: configuration) 63 | } else { 64 | appState = .unauthenticated 65 | router.presentedSheet = .auth 66 | } 67 | } 68 | } 69 | } 70 | } 71 | 72 | private func refreshEnvWith(configuration: ATProtocolConfiguration) async { 73 | do { 74 | let client = await BSkyClient(configuration: configuration) 75 | let currentUser = try await CurrentUser(client: client) 76 | appState = .authenticated(client: client, currentUser: currentUser) 77 | } catch { 78 | appState = .error(error) 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /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/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/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/PostUI/List/Base/PostsListView.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import DesignSystem 3 | import Models 4 | import Client 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 | switch state { 15 | case .loading, .uninitialized: 16 | placeholderView 17 | case let .loaded(posts, cursor): 18 | ForEach(posts) { post in 19 | PostRowView(post: post) 20 | } 21 | if cursor != nil { 22 | nextPageView 23 | } 24 | case let .error(error): 25 | Text(error.localizedDescription) 26 | } 27 | } 28 | .navigationTitle(datasource.title) 29 | .screenContainer() 30 | .task { 31 | if case .uninitialized = state { 32 | state = .loading 33 | state = await datasource.loadPosts(with: state) 34 | } 35 | } 36 | .refreshable { 37 | state = .loading 38 | state = await datasource.loadPosts(with: state) 39 | } 40 | } 41 | 42 | private var nextPageView: some View { 43 | HStack { 44 | ProgressView() 45 | } 46 | .task { 47 | state = await datasource.loadPosts(with: state) 48 | } 49 | } 50 | 51 | private var placeholderView: some View { 52 | ForEach(PostItem.placeholders) { post in 53 | PostRowView(post: post) 54 | .redacted(reason: .placeholder) 55 | .allowsHitTesting(false) 56 | } 57 | } 58 | } 59 | 60 | // MARK: - Data 61 | extension PostListView { 62 | public static func processFeed(_ feed: [AppBskyLexicon.Feed.FeedViewPostDefinition]) -> [PostItem] 63 | { 64 | var postItems: [PostItem] = [] 65 | 66 | func insert(post: AppBskyLexicon.Feed.PostViewDefinition, hasReply: Bool) { 67 | guard !postItems.contains(where: { $0.uri == post.postItem.uri }) else { return } 68 | var item = post.postItem 69 | item.hasReply = hasReply 70 | postItems.append(item) 71 | } 72 | 73 | for post in feed { 74 | if let reply = post.reply { 75 | switch reply.root { 76 | case let .postView(post): 77 | insert(post: post, hasReply: true) 78 | 79 | switch reply.parent { 80 | case let .postView(parent): 81 | insert(post: parent, hasReply: true) 82 | default: 83 | break 84 | } 85 | default: 86 | break 87 | } 88 | } 89 | insert(post: post.post, hasReply: false) 90 | 91 | } 92 | return postItems 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /IcySky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "24c95b404ee3914065b3d0fea5f1884e1e95c2a141b456b37cabcc4cbe57502a", 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/MasterJ93/ATProtoKit", 17 | "state" : { 18 | "revision" : "9d4b9f4bb61fba8e2b1303ef232a860e6b1652b0", 19 | "version" : "0.28.0" 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" : "viewinspector", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/nalexn/ViewInspector", 80 | "state" : { 81 | "revision" : "788e7879d38a839c4e348ab0762dcc0364e646a2", 82 | "version" : "0.10.1" 83 | } 84 | } 85 | ], 86 | "version" : 3 87 | } 88 | -------------------------------------------------------------------------------- /Packages/Features/Sources/PostUI/Detail/PostDetailView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Models 5 | import Client 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 | ForEach(parents) { parent in 25 | PostRowView(post: parent) 26 | } 27 | 28 | PostRowView(post: post) 29 | .environment(\.isFocused, true) 30 | .id("focusedPost") 31 | 32 | ForEach(replies) { reply in 33 | PostRowView(post: reply) 34 | } 35 | 36 | VStack {} 37 | .frame(height: 300) 38 | .listRowSeparator(.hidden) 39 | } 40 | .screenContainer() 41 | .task { 42 | await fetchThread() 43 | if !parents.isEmpty { 44 | scrollToId = "focusedPost" 45 | } 46 | } 47 | .onChange(of: scrollToId) { 48 | if let scrollToId { 49 | proxy.scrollTo(scrollToId, anchor: .top) 50 | } 51 | } 52 | .navigationTitle("Post") 53 | } 54 | } 55 | 56 | private func fetchThread() async { 57 | do { 58 | let thread = try await client.protoClient.getPostThread(from: post.uri) 59 | switch thread.thread { 60 | case .threadViewPost(let threadViewPost): 61 | self.post = threadViewPost.post.postItem 62 | processParents(from: threadViewPost) 63 | processReplies(from: threadViewPost) 64 | default: 65 | break 66 | } 67 | } catch { 68 | print(error) 69 | } 70 | } 71 | 72 | private func processParents(from threadViewPost: AppBskyLexicon.Feed.ThreadViewPostDefinition) { 73 | if let parent = threadViewPost.parent { 74 | switch parent { 75 | case .threadViewPost(let post): 76 | var item = post.post.postItem 77 | item.hasReply = true 78 | self.parents.append(item) 79 | processParents(from: post) 80 | default: 81 | break 82 | } 83 | } 84 | } 85 | 86 | private func processReplies(from threadViewPost: AppBskyLexicon.Feed.ThreadViewPostDefinition) { 87 | if let replies = threadViewPost.replies { 88 | for reply in replies { 89 | switch reply { 90 | case .threadViewPost(let reply): 91 | var postItem = reply.post.postItem 92 | if reply.replies?.isEmpty == false { 93 | postItem.hasReply = true 94 | } 95 | self.replies.append(postItem) 96 | processReplies(from: reply) 97 | default: 98 | break 99 | } 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /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/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.2 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: "Client", 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(.v26), .macOS(.v26)], 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 | .library(name: "ComposerUI", targets: ["ComposerUI"]), 28 | ], 29 | dependencies: [ 30 | .package(name: "Model", path: "../Model"), 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: "ComposerUI", 80 | dependencies: baseDeps 81 | ), 82 | .target( 83 | name: "DesignSystem", 84 | dependencies: [ 85 | .product(name: "Destinations", package: "Model"), 86 | "AppRouter", 87 | ] 88 | ), 89 | .testTarget( 90 | name: "DesignSystemTests", 91 | dependencies: [ 92 | "DesignSystem", 93 | .product(name: "ViewInspector", package: "ViewInspector"), 94 | ] 95 | ), 96 | ] 97 | ) 98 | -------------------------------------------------------------------------------- /Packages/Features/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "344546b5df0c16993c0a7abf145f31c0b058bd8471b6708634a39785c6a29017", 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/MasterJ93/ATProtoKit", 17 | "state" : { 18 | "revision" : "9d4b9f4bb61fba8e2b1303ef232a860e6b1652b0", 19 | "version" : "0.28.0" 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" : "671108c96644956dddcd89dd59c203dcdb36cec7", 46 | "version" : "1.1.4" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-log", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/apple/swift-log.git", 53 | "state" : { 54 | "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", 55 | "version" : "1.6.1" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-syntax", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/swiftlang/swift-syntax.git", 62 | "state" : { 63 | "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", 64 | "version" : "509.1.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" : "a6fcac8485bc8f57b2d2b55bb6d97138e8659e4b", 91 | "version" : "0.10.2" 92 | } 93 | } 94 | ], 95 | "version" : 3 96 | } 97 | -------------------------------------------------------------------------------- /IcySky.xcodeproj/xcshareddata/xcschemes/FeaturesTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 35 | 36 | 37 | 38 | 48 | 50 | 56 | 57 | 58 | 59 | 65 | 67 | 73 | 74 | 75 | 76 | 78 | 79 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /Packages/Features/Sources/NotificationsUI/NotificationsListView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Models 5 | import Client 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/FeedUI/Row/FeedRowView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Destinations 5 | import Models 6 | import Client 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/List/FeedsListView.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Models 5 | import Client 6 | import SwiftUI 7 | import User 8 | 9 | public struct FeedsListView: View { 10 | @Environment(BSkyClient.self) var client 11 | @Environment(CurrentUser.self) var currentUser 12 | 13 | @State var feeds: [FeedItem] = [] 14 | @State var filter: FeedsListFilter = .suggested 15 | 16 | @State var isRecentFeedExpanded: Bool = true 17 | 18 | @State var isInSearch: Bool = false 19 | @State var searchText: String = "" 20 | 21 | @State var error: Error? 22 | 23 | @FocusState var isSearchFocused: Bool 24 | 25 | public init() {} 26 | 27 | public var body: some View { 28 | List { 29 | headerView 30 | .padding(.bottom, 16) 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IcySky 🧊✨ 2 | 3 | [![Tuist Preview](https://tuist.dev/Dimillian/IcySky/previews/latest/badge.svg)](https://tuist.dev/Dimillian/IcySky/previews/latest) 4 | 5 | A modern, beautiful Bluesky client for iOS built with SwiftUI and the latest iOS 26 SDK features. 6 | 7 | ## 🌟 About 8 | 9 | IcySky (formerly known as GlowSky, RetroSky) is a native iOS client for [Bluesky](https://bsky.social), the decentralized social network built on the AT Protocol. This project showcases modern iOS development practices with a focus on beautiful, fluid UI powered by the latest SwiftUI capabilities. 10 | 11 | ### ✨ Features 12 | 13 | - **Liquid Glass UI**: Leverages iOS 26's new Liquid Glass effects for a stunning, modern interface 14 | - **Native Performance**: Built entirely in SwiftUI for smooth, responsive interactions 15 | - **Modular Architecture**: Clean separation between UI and business logic using Swift Package Manager 16 | - **AT Protocol Integration**: Full support for Bluesky's decentralized social features 17 | - **Secure Authentication**: Login with app passwords, stored securely in iOS Keychain 18 | 19 | ### 🚧 Current Status 20 | 21 | This is an early-stage project focused on establishing a solid foundation and exploring innovative UI designs. Currently implemented: 22 | 23 | - ✅ Secure authentication with app passwords 24 | - ✅ Feed browsing and exploration 25 | - ✅ Beautiful, custom-designed UI components 26 | - ✅ Modular package architecture 27 | - 🏗️ Post composition (in progress) 28 | - 🏗️ Profile viewing (in progress) 29 | - 🏗️ Notifications (in progress) 30 | 31 | ## 📱 Screenshots 32 | 33 | 34 | 35 | 36 | ## 🛠️ Technical Details 37 | 38 | ### Requirements 39 | 40 | - **iOS 26.0+** 41 | - **Xcode 26.0+** with iOS 26 SDK 42 | - **Swift 6.2+** 43 | 44 | ### Architecture 45 | 46 | IcySky follows a modular architecture with two main Swift packages: 47 | 48 | - **Features Package**: All UI components and views 49 | - AuthUI, FeedUI, PostUI, ProfileUI, etc. 50 | - Custom DesignSystem with reusable components 51 | 52 | - **Model Package**: Core business logic and data 53 | - Network layer (AT Protocol client) 54 | - Data models and state management 55 | - Authentication and user management 56 | 57 | ### Key Technologies 58 | 59 | - **SwiftUI** with iOS 26's Liquid Glass effects 60 | - **Swift Observation** framework for state management 61 | - **AT Protocol** via ATProtoKit 62 | - **Async/Await** for modern concurrency 63 | - **KeychainSwift** for secure credential storage 64 | 65 | ## 🚀 Getting Started 66 | 67 | 1. Clone the repository: 68 | ```bash 69 | git clone https://github.com/Dimillian/IcySky.git 70 | ``` 71 | 72 | 2. Open in Xcode: 73 | ```bash 74 | cd IcySky 75 | open IcySky.xcodeproj 76 | ``` 77 | 78 | 3. Build and run on an iOS 26 simulator or device 79 | 80 | ### Development 81 | 82 | The project uses a no-ViewModel approach, embracing SwiftUI's native patterns: 83 | - Views as pure state expressions 84 | - Environment-based dependency injection 85 | - Local state management with enums 86 | - Service classes for business logic 87 | 88 | For detailed development guidelines, see [CLAUDE.md](CLAUDE.md). 89 | 90 | ## 🤝 Contributing 91 | 92 | While this is currently a personal exploration project, feedback and ideas are welcome! Feel free to: 93 | - Open issues for bugs or feature requests 94 | - Share UI/UX suggestions 95 | - Discuss architectural improvements 96 | 97 | ## 📄 License 98 | 99 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 100 | 101 | ## 🙏 Acknowledgments 102 | 103 | - Built with [ATProtoKit](https://github.com/MasterJ93/ATProtoKit) for AT Protocol support 104 | - Inspired by the Bluesky community and the vision of decentralized social networking 105 | 106 | --- 107 | 108 | *IcySky is an independent project and is not affiliated with Bluesky PBLLC.* 109 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 isSaved: Bool = false 17 | @State private var scrollPosition: Media? 18 | @State private var isAltVisible: Bool = false 19 | 20 | @GestureState private var zoom = 1.0 21 | 22 | public init(images: [Media], preloadedImage: URL?, namespace: Namespace.ID) { 23 | self.images = images 24 | self.preloadedImage = preloadedImage 25 | self.namespace = namespace 26 | } 27 | 28 | var firstImageURL: URL? { 29 | if let preloadedImage, !isFirstImageLoaded { 30 | return preloadedImage 31 | } 32 | return images.first?.url 33 | } 34 | 35 | public var body: some View { 36 | NavigationStack { 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 | .toolbar { 70 | leadingToolbar 71 | trailingToolbar 72 | } 73 | .scrollContentBackground(.hidden) 74 | .scrollTargetBehavior(.viewAligned) 75 | .task { 76 | scrollPosition = images.first 77 | do { 78 | let data = try await ImagePipeline.shared.data(for: .init(url: images.first?.url)) 79 | if !data.0.isEmpty { 80 | self.isFirstImageLoaded = true 81 | } 82 | } catch {} 83 | } 84 | } 85 | .navigationTransition(.zoom(sourceID: images[0].id, in: namespace)) 86 | } 87 | 88 | private var leadingToolbar: some ToolbarContent { 89 | ToolbarItem(placement: .navigationBarLeading) { 90 | Button { 91 | dismiss() 92 | } label: { 93 | Image(systemName: "xmark") 94 | .foregroundStyle(.redPurple) 95 | } 96 | } 97 | } 98 | 99 | private var trailingToolbar: some ToolbarContent { 100 | ToolbarItemGroup(placement: .navigationBarTrailing) { 101 | saveButton 102 | shareButton 103 | } 104 | } 105 | 106 | private var saveButton: some View { 107 | Button { 108 | Task { 109 | do { 110 | guard let imageURL = scrollPosition?.url else { return } 111 | let data = try await ImagePipeline.shared.data(for: .init(url: imageURL)) 112 | if !data.0.isEmpty, let image = UIImage(data: data.0) { 113 | UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) 114 | withAnimation { 115 | isSaved = true 116 | } 117 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { 118 | withAnimation { 119 | isSaved = false 120 | } 121 | } 122 | } 123 | } catch {} 124 | } 125 | } label: { 126 | Image(systemName: isSaved ? "checkmark" : "arrow.down.circle") 127 | .foregroundStyle(.indigoPurple) 128 | } 129 | } 130 | 131 | @ViewBuilder 132 | private var shareButton: some View { 133 | if let imageURL = scrollPosition?.url { 134 | ShareLink(item: imageURL) { 135 | Image(systemName: "square.and.arrow.up") 136 | .foregroundStyle(.indigoPurple) 137 | } 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Packages/Features/Sources/NotificationsUI/NotificationsGroup.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import Foundation 3 | import Models 4 | import Client 5 | import SwiftUI 6 | 7 | struct NotificationsGroup: Identifiable { 8 | let id: String 9 | let timestamp: Date 10 | let type: AppBskyLexicon.Notification.Notification.Reason 11 | let notifications: [AppBskyLexicon.Notification.Notification] 12 | let postItem: PostItem? 13 | 14 | static func groupNotifications( 15 | client: BSkyClient, 16 | _ notifications: [AppBskyLexicon.Notification.Notification] 17 | ) async -> [NotificationsGroup] { 18 | var groups: [NotificationsGroup] = [] 19 | var groupedNotifications: 20 | [AppBskyLexicon.Notification.Notification.Reason: [String: [AppBskyLexicon.Notification 21 | .Notification]]] = [:] 22 | 23 | // Sort notifications by date 24 | let sortedNotifications = notifications.sorted { $0.indexedAt > $1.indexedAt } 25 | 26 | let postsURIs = 27 | Array( 28 | Set( 29 | sortedNotifications 30 | .filter { $0.reason != .follow } 31 | .compactMap { $0.postURI } 32 | )) 33 | var postItems: [PostItem] = [] 34 | do { 35 | postItems = try await client.protoClient.getPosts(postsURIs).posts.map { $0.postItem } 36 | } catch { 37 | postItems = [] 38 | } 39 | 40 | for notification in sortedNotifications { 41 | let reason = notification.reason 42 | 43 | if reason.shouldGroup { 44 | // Group notifications by type and subject 45 | let key = notification.reasonSubjectURI ?? "general" 46 | groupedNotifications[reason, default: [:]][key, default: []].append(notification) 47 | } else { 48 | // Create individual groups for non-grouped notifications 49 | groups.append( 50 | NotificationsGroup( 51 | id: notification.uri, 52 | timestamp: notification.indexedAt, 53 | type: reason, 54 | notifications: [notification], 55 | postItem: postItems.first(where: { $0.uri == notification.postURI }) 56 | )) 57 | } 58 | } 59 | 60 | // Add grouped notifications 61 | for (reason, subjectGroups) in groupedNotifications { 62 | for (subjectURI, notifications) in subjectGroups { 63 | groups.append( 64 | NotificationsGroup( 65 | id: "\(reason)-\(subjectURI)-\(notifications[0].indexedAt.timeIntervalSince1970)", 66 | timestamp: notifications[0].indexedAt, 67 | type: reason, 68 | notifications: notifications, 69 | postItem: postItems.first(where: { $0.uri == notifications[0].postURI }) 70 | )) 71 | } 72 | } 73 | 74 | // Sort all groups by timestamp 75 | return groups.sorted { $0.timestamp > $1.timestamp } 76 | } 77 | } 78 | 79 | extension AppBskyLexicon.Notification.Notification.Reason: @retroactive Hashable, @retroactive 80 | Equatable 81 | { 82 | public func hash(into hasher: inout Hasher) { 83 | hasher.combine(self.rawValue) 84 | } 85 | 86 | public static func == ( 87 | lhs: AppBskyLexicon.Notification.Notification.Reason, 88 | rhs: AppBskyLexicon.Notification.Notification.Reason 89 | ) -> Bool { 90 | lhs.rawValue == rhs.rawValue 91 | } 92 | } 93 | 94 | extension AppBskyLexicon.Notification.Notification { 95 | fileprivate var postURI: String? { 96 | switch reason { 97 | case .follow, .starterpackjoined: return nil 98 | case .like, .repost: return reasonSubjectURI 99 | case .reply, .mention, .quote: return uri 100 | default: return nil 101 | } 102 | } 103 | } 104 | 105 | extension AppBskyLexicon.Notification.Notification.Reason { 106 | fileprivate var shouldGroup: Bool { 107 | switch self { 108 | case .like, .follow, .repost: 109 | return true 110 | case .reply, .mention, .quote, .starterpackjoined: 111 | return false 112 | default: 113 | return false 114 | } 115 | } 116 | 117 | var iconName: String { 118 | switch self { 119 | case .like: return "heart.fill" 120 | case .follow: return "person.fill.badge.plus" 121 | case .repost: return "quote.opening" 122 | case .mention: return "at" 123 | case .quote: return "quote.opening" 124 | case .reply: return "arrowshape.turn.up.left.fill" 125 | case .starterpackjoined: return "star" 126 | default: return "bell.fill" // Fallback for unknown reasons 127 | } 128 | } 129 | 130 | var color: Color { 131 | switch self { 132 | case .like: return .pink 133 | case .follow: return .blue 134 | case .repost: return .green 135 | case .mention: return .purple 136 | case .quote: return .orange 137 | case .reply: return .teal 138 | case .starterpackjoined: return .yellow 139 | default: return .gray // Fallback for unknown reasons 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Packages/Model/Sources/Models/PostItem.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import Foundation 3 | 4 | public struct PostItem: Hashable, Identifiable, Equatable, Sendable { 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 | public static let 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/Features/Sources/PostUI/Row/PostRowView.swift: -------------------------------------------------------------------------------- 1 | import ATProtoKit 2 | import AppRouter 3 | import DesignSystem 4 | import Destinations 5 | import Models 6 | import Client 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.avatarBorder(hasReply: post.hasReply), 80 | lineWidth: 1) 81 | } 82 | .shadow(color: .shadowPrimary.opacity(0.3), radius: 2) 83 | .onTapGesture { 84 | router.navigateTo(.profile(post.author)) 85 | } 86 | } 87 | 88 | private var authorView: some View { 89 | HStack(alignment: isQuote ? .center : .firstTextBaseline) { 90 | if isQuote { 91 | avatarView 92 | } 93 | Text(post.author.displayName ?? "") 94 | .font(.callout) 95 | .foregroundStyle(.primary) 96 | .fontWeight(.semibold) 97 | + Text(" @\(post.author.handle)") 98 | .font(.footnote) 99 | .foregroundStyle(.tertiary) 100 | Spacer() 101 | Text(post.indexAtFormatted) 102 | .font(.caption) 103 | .foregroundStyle(.secondary) 104 | } 105 | .lineLimit(1) 106 | .onTapGesture { 107 | router.navigateTo(.profile(post.author)) 108 | } 109 | } 110 | 111 | @ViewBuilder 112 | private var threadLineView: some View { 113 | if post.hasReply { 114 | Rectangle() 115 | .frame(width: 1) 116 | .frame(maxHeight: .infinity) 117 | .foregroundStyle( 118 | LinearGradient.indigoPurple 119 | .shadow(.drop(color: .indigo, radius: 3))) 120 | } 121 | } 122 | } 123 | 124 | #Preview { 125 | NavigationStack { 126 | List { 127 | PostRowView( 128 | post: .init( 129 | uri: "", 130 | cid: "", 131 | indexedAt: Date(), 132 | author: .init( 133 | did: "", 134 | handle: "dimillian", 135 | displayName: "Thomas Ricouard", 136 | avatarImageURL: nil), 137 | content: "Just some content", 138 | replyCount: 10, 139 | repostCount: 150, 140 | likeCount: 38, 141 | likeURI: nil, 142 | repostURI: nil, 143 | embed: nil, 144 | replyRef: nil)) 145 | PostRowView( 146 | post: .init( 147 | uri: "", 148 | cid: "", 149 | indexedAt: Date(), 150 | author: .init( 151 | did: "", 152 | handle: "dimillian", 153 | displayName: "Thomas Ricouard", 154 | avatarImageURL: nil), 155 | content: "Just some content", 156 | replyCount: 10, 157 | repostCount: 150, 158 | likeCount: 38, 159 | likeURI: nil, 160 | repostURI: nil, 161 | embed: nil, 162 | replyRef: nil)) 163 | PostRowEmbedQuoteView( 164 | post: .init( 165 | uri: "", 166 | cid: "", 167 | indexedAt: Date(), 168 | author: .init( 169 | did: "", 170 | handle: "dimillian", 171 | displayName: "Thomas Ricouard", 172 | avatarImageURL: nil), 173 | content: "Just some content", 174 | replyCount: 10, 175 | repostCount: 150, 176 | likeCount: 38, 177 | likeURI: "", 178 | repostURI: "", 179 | embed: nil, 180 | replyRef: nil)) 181 | } 182 | .listStyle(.plain) 183 | .environment(AppRouter(initialTab: .feed)) 184 | .environment(PostContextProvider()) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /Packages/Features/Sources/DesignSystem/Header/HeaderVIew.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct HeaderTitleShadowModifier: ViewModifier { 4 | public init() {} 5 | 6 | public func body(content: Content) -> some View { 7 | content 8 | .foregroundStyle( 9 | .primary.shadow( 10 | .inner( 11 | color: .shadowSecondary.opacity(0.5), 12 | radius: 1, x: -1, y: -1)) 13 | ) 14 | .shadow(color: .black.opacity(0.2), radius: 1, x: 1, y: 1) 15 | } 16 | } 17 | 18 | extension View { 19 | public func headerTitleShadow() -> some View { 20 | modifier(HeaderTitleShadowModifier()) 21 | } 22 | } 23 | 24 | public enum HeaderType { 25 | case navigation // Back button (chevron.backward) 26 | case modal // Close button (xmark) 27 | case titleOnly // No action button 28 | case custom(systemImage: String, action: () -> Void) // Custom button 29 | } 30 | 31 | public enum HeaderFontSize { 32 | case title 33 | case largeTitle 34 | 35 | var font: Font { 36 | switch self { 37 | case .title: return .title 38 | case .largeTitle: return .largeTitle 39 | } 40 | } 41 | } 42 | 43 | public struct HeaderView: View { 44 | @Environment(\.dismiss) var dismiss 45 | let title: String 46 | let subtitle: String? 47 | let type: HeaderType 48 | let fontSize: HeaderFontSize 49 | let alignment: HorizontalAlignment 50 | 51 | // Legacy initializer for backward compatibility 52 | public init( 53 | title: String, 54 | subtitle: String? = nil, 55 | showBack: Bool = true 56 | ) { 57 | self.title = title 58 | self.subtitle = subtitle 59 | self.type = showBack ? .navigation : .titleOnly 60 | self.fontSize = .title 61 | self.alignment = .leading 62 | } 63 | 64 | // New initializer with enhanced options 65 | public init( 66 | title: String, 67 | subtitle: String? = nil, 68 | type: HeaderType = .navigation, 69 | fontSize: HeaderFontSize = .title, 70 | alignment: HorizontalAlignment = .leading 71 | ) { 72 | self.title = title 73 | self.subtitle = subtitle 74 | self.type = type 75 | self.fontSize = fontSize 76 | self.alignment = alignment 77 | } 78 | 79 | private var paddingForType: CGFloat { 80 | switch type { 81 | case .navigation, .titleOnly: 82 | return 0 // Original behavior - no horizontal padding 83 | case .modal, .custom: 84 | return 16 // New modal/custom headers get padding 85 | } 86 | } 87 | 88 | public var body: some View { 89 | HStack(alignment: .center) { 90 | switch type { 91 | case .titleOnly: 92 | EmptyView() 93 | case .navigation: 94 | actionButton 95 | case .modal, .custom: 96 | actionButton 97 | .padding(.trailing, 12) 98 | } 99 | 100 | // Title section 101 | titleSection 102 | 103 | Spacer() 104 | } 105 | .headerTitleShadow() 106 | .font(fontSize.font) 107 | .fontWeight(.bold) 108 | .listRowSeparator(.hidden) 109 | .padding(.horizontal, paddingForType) 110 | .padding(.vertical, fontSize == .largeTitle ? 16 : 8) 111 | } 112 | 113 | @ViewBuilder 114 | private var titleSection: some View { 115 | VStack(alignment: alignment, spacing: 4) { 116 | Text(title) 117 | .lineLimit(1) 118 | .minimumScaleFactor(0.5) 119 | if let subtitle { 120 | Text(subtitle) 121 | .foregroundStyle(.secondary) 122 | .font(.callout) 123 | .lineLimit(1) 124 | .minimumScaleFactor(0.5) 125 | } 126 | } 127 | .frame(maxWidth: .infinity, alignment: alignment == .leading ? .leading : .center) 128 | } 129 | 130 | @ViewBuilder 131 | private var actionButton: some View { 132 | switch type { 133 | case .navigation: 134 | Button { 135 | dismiss() 136 | } label: { 137 | Image(systemName: "chevron.backward") 138 | .id("back") 139 | } 140 | .buttonStyle(.plain) 141 | 142 | case .modal: 143 | Button { 144 | dismiss() 145 | } label: { 146 | Image(systemName: "xmark") 147 | .font(.body) 148 | .padding(.all, 12) 149 | } 150 | .buttonStyle(.glass) 151 | .foregroundColor(.primary) 152 | 153 | case .custom(let systemImage, let action): 154 | Button { 155 | action() 156 | } label: { 157 | Image(systemName: systemImage) 158 | .font(.body) 159 | .padding(.all, 8) 160 | } 161 | .buttonStyle(.glass) 162 | .foregroundColor(.primary) 163 | 164 | case .titleOnly: 165 | EmptyView() 166 | } 167 | } 168 | } 169 | 170 | #Preview("Navigation Header") { 171 | HeaderView( 172 | title: "Navigation Title", 173 | subtitle: "Optional subtitle", 174 | type: .navigation 175 | ) 176 | .padding() 177 | } 178 | 179 | #Preview("Modal Header") { 180 | HeaderView( 181 | title: "Modal Title", 182 | type: .modal 183 | ) 184 | .padding() 185 | } 186 | 187 | #Preview("Title Only") { 188 | HeaderView( 189 | title: "Title Only Header", 190 | subtitle: "No action buttons", 191 | type: .titleOnly 192 | ) 193 | .padding() 194 | } 195 | 196 | #Preview("Custom Header") { 197 | HeaderView( 198 | title: "Custom Action", 199 | subtitle: "With gear icon", 200 | type: .custom(systemImage: "gear") { 201 | print("Custom action tapped") 202 | } 203 | ) 204 | .padding() 205 | } 206 | 207 | #Preview("Large Title") { 208 | HeaderView( 209 | title: "Large Title Header", 210 | subtitle: "Bigger font size", 211 | type: .navigation, 212 | fontSize: .largeTitle 213 | ) 214 | .padding() 215 | } 216 | 217 | #Preview("Center Aligned") { 218 | HeaderView( 219 | title: "Centered Title", 220 | subtitle: "Center alignment", 221 | type: .modal, 222 | fontSize: .title, 223 | alignment: .center 224 | ) 225 | .padding() 226 | } 227 | 228 | #Preview("All Configurations") { 229 | VStack(spacing: 20) { 230 | HeaderView(title: "Navigation", type: .navigation) 231 | Divider() 232 | HeaderView(title: "Modal", type: .modal) 233 | Divider() 234 | HeaderView(title: "Title Only", type: .titleOnly) 235 | Divider() 236 | HeaderView( 237 | title: "Custom", 238 | type: .custom(systemImage: "star") {} 239 | ) 240 | Divider() 241 | HeaderView( 242 | title: "Large Title", 243 | type: .titleOnly, 244 | fontSize: .largeTitle 245 | ) 246 | } 247 | .padding() 248 | } 249 | -------------------------------------------------------------------------------- /TextEditor.md: -------------------------------------------------------------------------------- 1 | # TextEditor with AttributedString Pattern Detection 2 | 3 | ## Overview 4 | 5 | This document explains how IcySky implements real-time pattern detection (for @mentions, #hashtags, and URLs) in a TextEditor using iOS 26's AttributedString APIs. If you're trying to build similar functionality, this guide will help you understand the approach and avoid common pitfalls. 6 | 7 | ## The Challenge 8 | 9 | When users type in a composer, we want to: 10 | - Automatically detect and highlight @mentions (e.g., `@john` in purple) 11 | - Automatically detect and highlight #hashtags (e.g., `#swift` in indigo) 12 | - Automatically detect and underline URLs (e.g., `https://example.com` in blue) 13 | - Do this in real-time as the user types, without any manual intervention 14 | 15 | ## The Architecture 16 | 17 | ### 1. Pattern Definition (`ComposerTextPattern.swift`) 18 | 19 | First, we define what patterns we're looking for: 20 | 21 | ```swift 22 | enum ComposerTextPattern: String, CaseIterable, Codable { 23 | case hashtag 24 | case mention 25 | case url 26 | 27 | var pattern: String { 28 | switch self { 29 | case .hashtag: return "#\\w+" 30 | case .mention: return "@[\\w.-]+" 31 | case .url: return "(?i)https?://(?:www\\.)?\\S+(?:/|\\b)" 32 | } 33 | } 34 | 35 | var color: Color { 36 | switch self { 37 | case .hashtag: return .purple 38 | case .mention: return .indigo 39 | case .url: return .blue 40 | } 41 | } 42 | } 43 | ``` 44 | 45 | ### 2. Custom Attribute (`TextPatternAttribute`) 46 | 47 | We create a custom attribute to mark text with its pattern type: 48 | 49 | ```swift 50 | struct TextPatternAttribute: CodableAttributedStringKey { 51 | typealias Value = ComposerTextPattern 52 | static let name = "IcySky.TextPatternAttribute" 53 | static let inheritedByAddedText: Bool = false 54 | } 55 | ``` 56 | 57 | ### 3. Text Processing (`ComposerTextProcessor.swift`) 58 | 59 | Here's where the magic happens - and where we solve a critical problem: 60 | 61 | ```swift 62 | func processText(_ text: inout AttributedString) { 63 | // CRITICAL: Create a fresh AttributedString from plain text 64 | let plainString = String(text.characters) 65 | var freshText = AttributedString(plainString) 66 | 67 | // Find all pattern matches 68 | for match in plainString.matches(of: combinedRegex) { 69 | // Apply pattern attribute to fresh text 70 | freshText[matchStart.. 86 | 87 | // In IcySkyApp 88 | .task { 89 | for await configuration in auth.configurationUpdates { 90 | if let configuration { 91 | await refreshEnvWith(configuration: configuration) 92 | } else { 93 | appState = .unauthenticated 94 | router.presentedSheet = .auth 95 | } 96 | } 97 | } 98 | 99 | ### Testing Approach 100 | - Swift Testing framework (modern replacement for XCTest) 101 | - ViewInspector for SwiftUI component testing 102 | - Test targets organized by package modules 103 | - `@MainActor` test classes for UI testing 104 | 105 | ## Development Notes 106 | 107 | ### Package Dependencies 108 | Features package depends on Model package. When adding new functionality: 109 | - UI components go in Features package 110 | - Business logic and data models go in Model package 111 | - Cross-package dependencies are explicitly declared in Package.swift 112 | 113 | ### Design System 114 | All UI components should use the DesignSystem module for consistency: 115 | - Custom colors defined in `Colors.swift` and `Colors.xcassets` 116 | - Reusable components like `Pill`, `Container`, `GlowingRoundedRectangle` 117 | - Custom button styles like `PillButtonStyle` 118 | 119 | ### Navigation 120 | - Uses AppRouter for declarative navigation 121 | - `RouterDestination` enum defines available destinations 122 | - `SheetDestination` enum defines modal presentations 123 | - Tab structure defined in `AppTab` enum 124 | 125 | ## SwiftUI Philosophy: No ViewModels 126 | 127 | This project follows a strict **no-ViewModel** approach, embracing SwiftUI's native design patterns: 128 | 129 | ### Core Principles 130 | - **Views as Pure State Expressions**: SwiftUI views are structs designed to be lightweight and disposable 131 | - **Environment-Based Dependency Injection**: Use `@Environment` for shared services instead of manual ViewModel injection 132 | - **Local State Management**: Use `@State` and enum-based view states directly within views 133 | - **Composition Over Abstraction**: Split complex views into smaller components rather than extracting logic to ViewModels 134 | 135 | ### Patterns to Follow 136 | - Define view states using enums (`.loading`, `.error`, `.loaded`) 137 | - Use `@Environment` to access shared services like `BSkyClient`, `Auth`, `CurrentUser` 138 | - Leverage `.task(id:)` and `.onChange()` modifiers for side effects and state reactions 139 | - Keep business logic in service classes, not in ViewModels 140 | - Test services and models independently; use ViewInspector for view testing when needed 141 | 142 | ### Example Structure 143 | ```swift 144 | struct ExampleView: View { 145 | @Environment(BSkyClient.self) private var client 146 | @State private var viewState: ViewState = .loading 147 | 148 | enum ViewState { 149 | case loading 150 | case error(String) 151 | case loaded([Item]) 152 | } 153 | 154 | var body: some View { 155 | // Pure state expression 156 | } 157 | } 158 | ``` 159 | 160 | This approach results in cleaner, more maintainable code that works with SwiftUI's design rather than against it. 161 | 162 | ## Build Verification Rule 163 | 164 | **IMPORTANT**: After making code changes, you MUST use the XcodeBuildMCP commands to build and verify the project compiles without errors: 165 | 166 | 1. First, discover available schemes: 167 | ``` 168 | mcp__XcodeBuildMCP__list_schems_proj({ projectPath: "/Users/thomas/Documents/Dev/Open Source/IcySky/IcySky.xcodeproj" }) 169 | ``` 170 | 171 | 2. Build the IcySky scheme for iOS Simulator: 172 | ``` 173 | mcp__XcodeBuildMCP__build_ios_sim_name_proj({ 174 | projectPath: "/Users/thomas/Documents/Dev/Open Source/IcySky/IcySky.xcodeproj", 175 | scheme: "IcySky", 176 | simulatorName: "iPhone 16" 177 | }) 178 | ``` 179 | 180 | 3. If there are build errors, fix them before considering the task complete. 181 | 182 | This ensures code changes are syntactically correct and don't break the build. 183 | 184 | ## iOS 26 SDK Requirements 185 | 186 | **IMPORTANT**: This project now requires iOS 26 SDK and targets iOS 26+ exclusively. We fully embrace and utilize the latest SwiftUI APIs introduced in June 2025. 187 | 188 | ### Available iOS 26 SwiftUI APIs 189 | 190 | Feel free to use any of these new APIs throughout the codebase: 191 | 192 | #### Liquid Glass Effects 193 | - `glassEffect(_:in:isEnabled:)` - Apply Liquid Glass effects to views 194 | - `buttonStyle(.glass)` - Apply Liquid Glass styling to buttons 195 | - `ToolbarSpacer` - Create visual breaks in toolbars with Liquid Glass 196 | 197 | #### Enhanced Scrolling 198 | - `scrollEdgeEffectStyle(_:for:)` - Configure scroll edge effects 199 | - `backgroundExtensionEffect()` - Duplicate, mirror, and blur views around edges 200 | 201 | #### Tab Bar Enhancements 202 | - `tabBarMinimizeBehavior(_:)` - Control tab bar minimization behavior 203 | - Search role for tabs with search field replacing tab bar 204 | - `TabViewBottomAccessoryPlacement` - Adjust accessory view content based on placement 205 | 206 | #### Web Integration 207 | - `WebView` and `WebPage` - Full control over browsing experience 208 | 209 | #### Drag and Drop 210 | - `draggable(_:_:)` - Drag multiple items 211 | - `dragContainer(for:id:in:selection:_:)` - Container for draggable views 212 | 213 | #### Animation 214 | - `@Animatable` macro - SwiftUI synthesizes custom animatable data properties 215 | 216 | #### UI Components 217 | - `Slider` with automatic tick marks when using step parameter 218 | - `windowResizeAnchor(_:)` - Set window anchor point for resizing 219 | 220 | #### Text Enhancements 221 | - `TextEditor` now supports `AttributedString` 222 | - `AttributedTextSelection` - Handle text selection with attributed text 223 | - `AttributedTextFormattingDefinition` - Define text styling in specific contexts 224 | - `FindContext` - Create find navigator in text editing views 225 | 226 | #### Accessibility 227 | - `AssistiveAccess` - Support Assistive Access in iOS scenes 228 | 229 | #### HDR Support 230 | - `Color.ResolvedHDR` - RGBA values with HDR headroom information 231 | 232 | #### UIKit Integration 233 | - `UIHostingSceneDelegate` - Host and present SwiftUI scenes in UIKit 234 | - `NSGestureRecognizerRepresentable` - Incorporate gesture recognizers from AppKit 235 | 236 | #### Immersive Spaces (if applicable) 237 | - `manipulable(coordinateSpace:operations:inertia:isEnabled:onChanged:)` - Hand gesture manipulation 238 | - `SurfaceSnappingInfo` - Snap volumes and windows to surfaces 239 | - `RemoteImmersiveSpace` - Render stereo content from Mac to Apple Vision Pro 240 | - `SpatialContainer` - 3D layout container 241 | - Depth-based modifiers: `aspectRatio3D(_:contentMode:)`, `rotation3DLayout(_:)`, `depthAlignment(_:)` 242 | 243 | ### Usage Guidelines 244 | - Leverage these new APIs to enhance the user experience 245 | - Replace legacy implementations with iOS 26 APIs where appropriate 246 | - Take advantage of Liquid Glass effects for modern UI aesthetics 247 | - Use the enhanced text and drag-and-drop capabilities for better interactions 248 | 249 | ## Text Processing with AttributedString (iOS 26) 250 | 251 | ### Overview 252 | The ComposerUI module implements automatic pattern detection for @mentions and #hashtags using iOS 26's AttributedString APIs. This implementation demonstrates how to work with TextEditor's behavior when implementing real-time pattern detection. 253 | 254 | ### Architecture 255 | 256 | **Location**: `Packages/Features/Sources/ComposerUI/TextProcessing/` 257 | 258 | **Key Components**: 259 | - `ComposerTextPattern.swift` - Defines patterns (hashtag, mention, URL) and their attributes 260 | - `ComposerTextProcessor.swift` - Processes text to detect and mark patterns 261 | - `ComposerFormattingDefinition.swift` - Applies visual styling using AttributedTextValueConstraint 262 | 263 | ### Implementation Details 264 | 265 | #### Custom Attributes 266 | ```swift 267 | struct TextPatternAttribute: CodableAttributedStringKey { 268 | typealias Value = ComposerTextPattern 269 | static let name = "IcySky.TextPatternAttribute" 270 | static let inheritedByAddedText: Bool = false 271 | } 272 | ``` 273 | 274 | #### Text Processing Approach 275 | Due to TextEditor creating fragmented character-by-character runs during typing, we must: 276 | 1. Create a fresh AttributedString from the plain text on each update 277 | 2. Apply all pattern attributes to the fresh string 278 | 3. Replace the entire text with the fresh version 279 | 280 | ```swift 281 | func processText(_ text: inout AttributedString) { 282 | let plainString = String(text.characters) 283 | var freshText = AttributedString(plainString) 284 | 285 | // Find and apply patterns to fresh text 286 | // This avoids fragmented runs from TextEditor 287 | } 288 | ``` 289 | 290 | #### Formatting Definition 291 | The `ComposerFormattingDefinition` uses constraints to automatically apply visual styling: 292 | - `PatternColorConstraint` - Applies colors based on text pattern 293 | - `URLUnderlineConstraint` - Underlines URLs 294 | 295 | Applied at the parent view level: 296 | ```swift 297 | .attributedTextFormattingDefinition(ComposerFormattingDefinition()) 298 | ``` 299 | 300 | ### Key Learnings 301 | 302 | 1. **TextEditor Behavior**: During active typing, TextEditor creates character-by-character AttributedString runs, causing fragmentation 303 | 304 | 2. **Apple's Approach vs Ours**: 305 | - Apple's sample (recipe editor) uses manual attribute application by user selection 306 | - Our approach requires automatic pattern detection during typing 307 | - This fundamental difference necessitates rebuilding the AttributedString 308 | 309 | 3. **Performance Optimization**: Process immediately for small changes (typing), debounce for large changes (paste) 310 | 311 | 4. **Pattern Matching**: Centralize regex patterns and matching logic in the enum to avoid duplication 312 | 313 | ### Important Notes 314 | 315 | - The fresh AttributedString approach is necessary for automatic pattern detection 316 | - AttributedTextFormattingDefinition constraints work elegantly for visual styling 317 | - This pattern can be adapted for other real-time text processing needs (e.g., syntax highlighting) 318 | - The implementation leverages iOS 26's enhanced TextEditor with AttributedString support --------------------------------------------------------------------------------