├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── images ├── icon.png └── image.png ├── swiftsky.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── .gitignore │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist └── swiftsky ├── Assets.xcassets ├── AccentColor.colorset │ └── Contents.json ├── AppIcon.appiconset │ ├── Contents.json │ └── icon.png ├── Contents.json └── default-feed.imageset │ ├── Contents.json │ └── default-feed.svg ├── Info.plist ├── Preview Content └── Preview Assets.xcassets │ └── Contents.json ├── api ├── api.swift ├── atproto │ ├── identityresolveHandle.swift │ ├── repocreateRecord.swift │ ├── repodeleteRecord.swift │ ├── repogetRecord.swift │ ├── repolistRecords.swift │ ├── repouploadBlob.swift │ ├── sessioncreate.swift │ ├── sessionget.swift │ └── sessionrefresh.swift ├── bsky │ ├── actordefs.swift │ ├── actorgetPreferences.swift │ ├── actorgetProfile.swift │ ├── actorputPreferences.swift │ ├── actorsearchActorsTypeahead.swift │ ├── embedexternal.swift │ ├── embedimages.swift │ ├── embedrecord.swift │ ├── feeddefs.swift │ ├── feedgetAuthorFeed.swift │ ├── feedgetFeed.swift │ ├── feedgetFeedGenerator.swift │ ├── feedgetFeedGenerators.swift │ ├── feedgetLikes.swift │ ├── feedgetPostThread.swift │ ├── feedgetPosts.swift │ ├── feedgetTimeline.swift │ ├── feedpost.swift │ ├── getPopularFeedGenerators.swift │ ├── graphgetFollowers.swift │ ├── graphgetFollows.swift │ ├── notificationlistNotifications.swift │ ├── notificationupdateSeen.swift │ ├── richtextfacet.swift │ ├── systemdeclRef.swift │ └── unspeccedgetPopular.swift ├── gtranslate.swift ├── keychain.swift ├── richtext.swift └── uri.swift ├── extensions.swift ├── models ├── GlobalViewModel.swift ├── Navigation.swift ├── PreferencesModel.swift ├── PushNotifications.swift └── TranslateViewModel.swift ├── swiftsky.entitlements ├── swiftskyApp.swift └── views ├── AvatarView.swift ├── DiscoverFeedsView.swift ├── EmbedExternalView.swift ├── EmbedPostView.swift ├── ErrorView.swift ├── FeedView.swift ├── FollowersView.swift ├── FollowsView.swift ├── GeneralSettingsView.swift ├── HomeView.swift ├── LoginView.swift ├── MenuButton.swift ├── NewPostView.swift ├── NotificationsView.swift ├── PostFooterView.swift ├── PostView.swift ├── ProfilePreview.swift ├── ProfileView.swift ├── SearchActorView.swift ├── SearchField.swift ├── SettingsView.swift ├── SidebarView.swift ├── TextViewWrapper.swift ├── ThreadPostView.swift ├── ThreadView.swift ├── ToolTipView.swift └── TranslateView.swift /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths-ignore: 7 | - '**.md' 8 | 9 | jobs: 10 | Build: 11 | runs-on: macos-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Build 15 | run: xcodebuild -project swiftsky.xcodeproj build -configuration Release 16 | - name: Create DMG 17 | run: npm i -g create-dmg && create-dmg build/Release/swiftsky.app 18 | continue-on-error: true 19 | - uses: actions/upload-artifact@v3 20 | with: 21 | name: swiftsky 22 | path: swiftsky*.dmg 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | .DS_Store 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 rmcan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # swiftsky 2 | An unofficial Bluesky/ATProto client for macOS built in SwiftUI 3 | 4 | ![Image](images/image.png?) 5 | 6 | # Download 7 | Download latest alpha [release](https://github.com/rmcan/swiftsky/releases/latest) 8 | 9 | # Requirements 10 | macOS 13.3+ 11 | 12 | # Building 13 | Xcode 14+ 14 | -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmcan/swiftsky/b37008a515c7d9f6eb159449bb880eb2dcb65e9d/images/icon.png -------------------------------------------------------------------------------- /images/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmcan/swiftsky/b37008a515c7d9f6eb159449bb880eb2dcb65e9d/images/image.png -------------------------------------------------------------------------------- /swiftsky.xcodeproj/project.xcworkspace/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmcan/swiftsky/b37008a515c7d9f6eb159449bb880eb2dcb65e9d/swiftsky.xcodeproj/project.xcworkspace/.gitignore -------------------------------------------------------------------------------- /swiftsky.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /swiftsky.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /swiftsky/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 | -------------------------------------------------------------------------------- /swiftsky/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "filename" : "icon.png", 45 | "idiom" : "mac", 46 | "scale" : "1x", 47 | "size" : "512x512" 48 | }, 49 | { 50 | "idiom" : "mac", 51 | "scale" : "2x", 52 | "size" : "512x512" 53 | } 54 | ], 55 | "info" : { 56 | "author" : "xcode", 57 | "version" : 1 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /swiftsky/Assets.xcassets/AppIcon.appiconset/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmcan/swiftsky/b37008a515c7d9f6eb159449bb880eb2dcb65e9d/swiftsky/Assets.xcassets/AppIcon.appiconset/icon.png -------------------------------------------------------------------------------- /swiftsky/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /swiftsky/Assets.xcassets/default-feed.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "default-feed.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /swiftsky/Assets.xcassets/default-feed.imageset/default-feed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /swiftsky/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleURLTypes 6 | 7 | 8 | CFBundleTypeRole 9 | Editor 10 | CFBundleURLName 11 | swiftsky 12 | CFBundleURLSchemes 13 | 14 | swiftsky 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /swiftsky/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /swiftsky/api/api.swift: -------------------------------------------------------------------------------- 1 | // 2 | // api.swift 3 | // swiftsky 4 | // 5 | 6 | import Combine 7 | import Foundation 8 | import SwiftUI 9 | 10 | struct AnyKey: CodingKey { 11 | var stringValue: String 12 | var intValue: Int? 13 | 14 | init?(stringValue: String) { 15 | self.stringValue = stringValue 16 | self.intValue = nil 17 | } 18 | init?(intValue: Int) { 19 | self.stringValue = String(intValue) 20 | self.intValue = intValue 21 | } 22 | } 23 | struct xrpcErrorDescription: Error, LocalizedError, Decodable { 24 | let error: String? 25 | let message: String? 26 | var errorDescription: String? { 27 | message 28 | } 29 | } 30 | 31 | class Client { 32 | static let shared = Client() 33 | private let baseURL = "https://bsky.social/xrpc/" 34 | private let decoder: JSONDecoder 35 | public var user = AuthData() 36 | @AppStorage("did") public var did: String = "" 37 | @AppStorage("handle") public var handle: String = "" 38 | enum httpMethod { 39 | case get 40 | case post 41 | } 42 | init() { 43 | self.decoder = JSONDecoder() 44 | self.decoder.dateDecodingStrategy = .custom({ decoder in 45 | let container = try decoder.singleValueContainer() 46 | let dateString = try container.decode(String.self) 47 | if let date = Formatter.iso8601withFractionalSeconds.date(from: dateString) { 48 | return date 49 | } 50 | if let date = Formatter.iso8601withTimeZone.date(from: dateString) { 51 | return date 52 | } 53 | throw DecodingError.dataCorruptedError( 54 | in: container, debugDescription: "Cannot decode date string \(dateString)") 55 | }) 56 | } 57 | public func postInit() { 58 | 59 | if let user = AuthData.load() { 60 | self.user = user 61 | let group = DispatchGroup() 62 | group.enter() 63 | Task { 64 | do { 65 | let session = try await xrpcSessionGet() 66 | if self.did == session.did, self.handle == session.handle { 67 | Auth.shared.needAuthorization = false 68 | } 69 | } catch { 70 | 71 | } 72 | group.leave() 73 | } 74 | group.wait() 75 | } 76 | } 77 | private func refreshSession() async -> Bool { 78 | do { 79 | let result: SessionRefreshOutput = try await self.fetch( 80 | endpoint: "com.atproto.server.refreshSession", httpMethod: .post, 81 | authorization: self.user.refreshJwt, params: Optional.none, retry: false) 82 | self.user.accessJwt = result.accessJwt 83 | self.user.refreshJwt = result.refreshJwt 84 | self.handle = result.handle 85 | self.did = result.did 86 | self.user.save() 87 | return true 88 | } catch { 89 | if error is xrpcErrorDescription { 90 | print(error) 91 | } 92 | } 93 | return false 94 | } 95 | func upload(endpoint: String, data: Data, authorization: String? = nil) async throws -> T { 96 | var request = URLRequest(url: URL(string: baseURL + endpoint)!) 97 | request.httpMethod = "POST" 98 | if let authorization { 99 | request.addValue("Bearer \(authorization)", forHTTPHeaderField: "Authorization") 100 | } 101 | let (data, response) = try await URLSession.shared.upload(for: request, from: data) 102 | guard let httpResponse = response as? HTTPURLResponse else { 103 | throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey : "Server error: 0"]) 104 | } 105 | if httpResponse.statusCode != 200 { 106 | throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey : "Server error: \(httpResponse.statusCode)"]) 107 | } 108 | return try self.decoder.decode(T.self, from: data) 109 | } 110 | func fetch( 111 | endpoint: String, httpMethod: httpMethod = .get, authorization: String? = nil, params: U? = nil, 112 | retry: Bool = true 113 | ) async throws -> T { 114 | guard var urlComponents = URLComponents(string: baseURL + endpoint) else { 115 | throw URLError(.badURL) 116 | } 117 | if httpMethod == .get, let params = params?.dictionary { 118 | urlComponents.queryItems = params.flatMap { 119 | if $0.hasSuffix("[]") { 120 | let name = $0 121 | return ($1 as! [Any]).map { 122 | URLQueryItem(name: name, value: "\($0)") 123 | } 124 | } 125 | return [URLQueryItem(name: $0, value: "\($1)")] 126 | } 127 | } 128 | guard let url = urlComponents.url else { 129 | throw URLError(.badURL) 130 | } 131 | 132 | var request = URLRequest(url: url) 133 | request.addValue("application/json", forHTTPHeaderField: "Accept") 134 | if let authorization { 135 | request.addValue("Bearer \(authorization)", forHTTPHeaderField: "Authorization") 136 | } 137 | switch httpMethod { 138 | case .get: 139 | request.httpMethod = "GET" 140 | case .post: 141 | request.httpMethod = "POST" 142 | request.addValue("application/json", forHTTPHeaderField: "Content-Type") 143 | if params != nil { 144 | request.httpBody = try? JSONEncoder().encode(params) 145 | request.addValue("\(request.httpBody?.count ?? 0)", forHTTPHeaderField: "Content-Length") 146 | } 147 | } 148 | 149 | let (data, response) = try await URLSession.shared.data(for: request) 150 | 151 | guard let httpResponse = response as? HTTPURLResponse else { 152 | throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey : "Server error: 0"]) 153 | } 154 | 155 | guard 200...299 ~= httpResponse.statusCode else { 156 | do { 157 | let xrpcerror = try self.decoder.decode(xrpcErrorDescription.self, from: data) 158 | if authorization != nil && retry == true { 159 | if xrpcerror.error == "ExpiredToken" { 160 | if await self.refreshSession() { 161 | return try await self.fetch( 162 | endpoint: endpoint, httpMethod: httpMethod, authorization: self.user.accessJwt, 163 | params: params, retry: false) 164 | } 165 | } 166 | if xrpcerror.error == "AuthenticationRequired" { 167 | DispatchQueue.main.async { 168 | Auth.shared.needAuthorization = true 169 | } 170 | } 171 | } 172 | 173 | throw xrpcerror 174 | } catch { 175 | if error is xrpcErrorDescription { 176 | throw error 177 | } 178 | throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey : "Server error: \(httpResponse.statusCode)"]) 179 | } 180 | } 181 | 182 | if T.self == Bool.self { 183 | return true as! T 184 | } 185 | 186 | return try self.decoder.decode(T.self, from: data) 187 | } 188 | } 189 | 190 | struct AuthData: Codable { 191 | var accessJwt: String = "" 192 | var refreshJwt: String = "" 193 | static func load() -> AuthData? { 194 | if let userdata = Keychain.get("app.swiftsky.userData"), 195 | let user = try? JSONDecoder().decode(AuthData.self, from: userdata) 196 | { 197 | return user 198 | } 199 | return nil 200 | } 201 | func save() { 202 | Task { 203 | if let userdata = try? JSONEncoder().encode(self) { 204 | Keychain.set(userdata, "app.swiftsky.userData") 205 | } 206 | } 207 | } 208 | } 209 | 210 | class Auth: ObservableObject { 211 | static let shared = Auth() 212 | @Published var needAuthorization: Bool = true 213 | public func signout() { 214 | GlobalViewModel.shared.profile = nil 215 | Client.shared.handle = "" 216 | Client.shared.did = "" 217 | Client.shared.user = .init() 218 | self.needAuthorization = true 219 | Task { 220 | Keychain.delete("app.swiftsky.userData") 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /swiftsky/api/atproto/identityresolveHandle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // identityresolveHandle.swift 3 | // swiftsky 4 | // 5 | 6 | struct IdentityResolveHandleOutput: Decodable, Hashable { 7 | let did: String 8 | } 9 | func IdentityResolveHandle(handle: String) async throws -> IdentityResolveHandleOutput { 10 | return try await Client.shared.fetch( 11 | endpoint: "com.atproto.identity.resolveHandle", authorization: Client.shared.user.accessJwt, 12 | params: ["handle" : handle]) 13 | } 14 | -------------------------------------------------------------------------------- /swiftsky/api/atproto/repocreateRecord.swift: -------------------------------------------------------------------------------- 1 | // 2 | // repocreateRecord.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct RepoCreateRecordOutput: Decodable { 9 | let cid: String 10 | let uri: String 11 | } 12 | struct LikePostInput: Encodable { 13 | let subject: RepoStrongRef 14 | let createdAt: String 15 | } 16 | struct RepostPostInput: Encodable { 17 | let subject: RepoStrongRef 18 | let createdAt: String 19 | } 20 | struct FollowUserInput: Encodable { 21 | let subject: String 22 | let createdAt: String 23 | } 24 | struct EmbedRefRecord: Encodable { 25 | let type = "app.bsky.embed.record" 26 | let record: RepoStrongRef 27 | enum CodingKeys: String, CodingKey { 28 | case type = "$type" 29 | case record 30 | } 31 | init(cid: String, uri: String) { 32 | self.record = RepoStrongRef(cid: cid, uri: uri) 33 | } 34 | } 35 | struct EmbedRefImage: Encodable { 36 | let type = "app.bsky.embed.images" 37 | let images: [EmbedImagesImage] 38 | enum CodingKeys: String, CodingKey { 39 | case type = "$type" 40 | case images 41 | } 42 | } 43 | struct EmbedRefRecordWithMedia: Encodable { 44 | let type = "app.bsky.embed.recordWithMedia" 45 | let record: EmbedRefRecord 46 | let media: EmbedRefImage 47 | enum CodingKeys: String, CodingKey { 48 | case type = "$type" 49 | case record 50 | case media 51 | } 52 | } 53 | struct EmbedRef: Encodable { 54 | let record: EmbedRefRecord? 55 | let images: EmbedRefImage? 56 | init(record: EmbedRefRecord? = nil, images: EmbedRefImage? = nil) { 57 | self.record = record 58 | self.images = images 59 | } 60 | func isValid() -> Bool { 61 | record != nil || images != nil 62 | } 63 | func encode(to encoder: Encoder) throws { 64 | var container = encoder.singleValueContainer() 65 | if let record, let images { 66 | try container.encode(EmbedRefRecordWithMedia(record: record, media: images)) 67 | } 68 | else if let record { 69 | try container.encode(record) 70 | } 71 | else if let images { 72 | try container.encode(images) 73 | } 74 | } 75 | } 76 | struct CreatePostInput: Encodable { 77 | let text: String 78 | let createdAt: String 79 | let embed: EmbedRef? 80 | let reply: FeedPostReplyRef? 81 | let facets: [RichtextFacet]? 82 | } 83 | struct RepoCreateRecordInput: Encodable { 84 | let type: String 85 | let collection: String 86 | let repo: String 87 | let record: T 88 | enum CodingKeys: String, CodingKey { 89 | case type = "$type" 90 | case collection 91 | case repo 92 | case record 93 | } 94 | } 95 | 96 | func repoCreateRecord(input: RepoCreateRecordInput) async throws 97 | -> RepoCreateRecordOutput 98 | { 99 | return try await Client.shared.fetch( 100 | endpoint: "com.atproto.repo.createRecord", httpMethod: .post, 101 | authorization: Client.shared.user.accessJwt, params: input) 102 | } 103 | func followUser(did: String) async throws -> RepoCreateRecordOutput { 104 | return try await repoCreateRecord( 105 | input: RepoCreateRecordInput( 106 | type: "app.bsky.graph.follow", collection: "app.bsky.graph.follow", 107 | repo: Client.shared.did, 108 | record: FollowUserInput( 109 | subject: did, 110 | createdAt: Date().iso8601withFractionalSeconds))) 111 | } 112 | func blockUser(did: String) async throws -> RepoCreateRecordOutput { 113 | return try await repoCreateRecord( 114 | input: RepoCreateRecordInput( 115 | type: "app.bsky.graph.block", collection: "app.bsky.graph.block", 116 | repo: Client.shared.did, 117 | record: ["subject": did, "createdAt" : Date().iso8601withFractionalSeconds, "$type" : "app.bsky.graph.block"])) 118 | } 119 | func makePost(text: String, reply: FeedPostReplyRef? = nil, facets: [RichtextFacet]? = nil, embed: EmbedRef? = nil) async throws -> RepoCreateRecordOutput { 120 | return try await repoCreateRecord( 121 | input: RepoCreateRecordInput( 122 | type: "app.bsky.feed.post", collection: "app.bsky.feed.post", repo: Client.shared.did, 123 | record: CreatePostInput( 124 | text: text, createdAt: Date().iso8601withFractionalSeconds, embed: embed, reply: reply, facets: facets))) 125 | } 126 | func likePost(uri: String, cid: String) async throws -> RepoCreateRecordOutput { 127 | return try await repoCreateRecord( 128 | input: RepoCreateRecordInput( 129 | type: "app.bsky.feed.like", collection: "app.bsky.feed.like", repo: Client.shared.did, 130 | record: LikePostInput( 131 | subject: RepoStrongRef(cid: cid, uri: uri), createdAt: Date().iso8601withFractionalSeconds))) 132 | } 133 | func RepostPost(uri: String, cid: String) async throws -> RepoCreateRecordOutput { 134 | return try await repoCreateRecord( 135 | input: RepoCreateRecordInput( 136 | type: "app.bsky.feed.repost", collection: "app.bsky.feed.repost", repo: Client.shared.did, 137 | record: RepostPostInput( 138 | subject: RepoStrongRef(cid: cid, uri: uri), createdAt: Date().iso8601withFractionalSeconds))) 139 | } 140 | -------------------------------------------------------------------------------- /swiftsky/api/atproto/repodeleteRecord.swift: -------------------------------------------------------------------------------- 1 | // 2 | // repodeleteRecord.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct RepoDeleteRecordInput: Encodable { 9 | let repo: String 10 | let collection: String 11 | let rkey: String 12 | } 13 | 14 | func repoDeleteRecord(uri: String, collection: String) async throws -> Bool { 15 | let aturi = AtUri(uri: uri) 16 | return try await Client.shared.fetch( 17 | endpoint: "com.atproto.repo.deleteRecord", httpMethod: .post, 18 | authorization: Client.shared.user.accessJwt, 19 | params: RepoDeleteRecordInput( 20 | repo: Client.shared.did, collection: collection, rkey: aturi.rkey)) 21 | } 22 | -------------------------------------------------------------------------------- /swiftsky/api/atproto/repogetRecord.swift: -------------------------------------------------------------------------------- 1 | // 2 | // repogetRecord.swift 3 | // swiftsky 4 | // 5 | 6 | struct RepoGetRecordOutput: Decodable { 7 | let cid: String? 8 | let uri: String 9 | // let value: FeedPost 10 | } 11 | struct RepoGetRecordInput: Encodable { 12 | let cid: String? 13 | let collection: String 14 | let repo: String 15 | let rkey: String 16 | } 17 | 18 | func RepoGetRecord(cid: String? = nil, collection: String, repo: String, rkey: String) async throws -> RepoGetRecordOutput { 19 | return try await Client.shared.fetch( 20 | endpoint: "com.atproto.repo.getRecord", httpMethod: .get, 21 | params: RepoGetRecordInput(cid: cid, collection: collection, repo: repo, rkey: rkey)) 22 | } 23 | -------------------------------------------------------------------------------- /swiftsky/api/atproto/repolistRecords.swift: -------------------------------------------------------------------------------- 1 | // 2 | // repolistRecords.swift 3 | // swiftsky 4 | // 5 | 6 | struct RepoListRecordsOutput: Decodable { 7 | let cursor: String? 8 | let records: [RepoListRecordsRecord] 9 | } 10 | struct RepoListRecordsValue: Codable { 11 | let subject: RepoStrongRef 12 | let createdAt: String 13 | } 14 | struct RepoListRecordsRecord: Decodable { 15 | let cid: String 16 | let uri: String 17 | let value: RepoListRecordsValue 18 | } 19 | struct RepoListRecordsInput: Encodable { 20 | let collection: String 21 | let cursor: String? 22 | let limit: Int? 23 | let repo: String 24 | } 25 | 26 | func RepoListRecords(collection: String, cursor: String? = nil, limit: Int? = nil, repo: String) async throws -> RepoListRecordsOutput { 27 | return try await Client.shared.fetch( 28 | endpoint: "com.atproto.repo.listRecords", httpMethod: .get, 29 | params: RepoListRecordsInput(collection: collection, cursor: cursor, limit: limit, repo: repo)) 30 | } 31 | 32 | -------------------------------------------------------------------------------- /swiftsky/api/atproto/repouploadBlob.swift: -------------------------------------------------------------------------------- 1 | // 2 | // repouploadBlob.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct RepoUploadBlobOutput: Decodable { 9 | let blob: LexBlob 10 | } 11 | func repouploadBlob(data: Data) async throws -> RepoUploadBlobOutput { 12 | return try await Client.shared.upload(endpoint: "com.atproto.repo.uploadBlob", data: data, authorization: Client.shared.user.accessJwt) 13 | } 14 | -------------------------------------------------------------------------------- /swiftsky/api/atproto/sessioncreate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // sessioncreate.swift 3 | // swiftsky 4 | // 5 | 6 | struct ServerCreateSessionInput: Encodable { 7 | let identifier: String 8 | let password: String 9 | } 10 | 11 | struct ServerCreateSessionOutput: Decodable, Hashable { 12 | let accessJwt: String 13 | let did: String 14 | let handle: String 15 | let refreshJwt: String 16 | } 17 | 18 | func ServerCreateSession(identifier: String, password: String) async throws -> ServerCreateSessionOutput { 19 | return try await Client.shared.fetch( 20 | endpoint: "com.atproto.server.createSession", httpMethod: .post, 21 | params: ServerCreateSessionInput(identifier: identifier, password: password)) 22 | } 23 | -------------------------------------------------------------------------------- /swiftsky/api/atproto/sessionget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // sessionget.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct SessionGetOutput: Decodable, Hashable { 9 | let did: String 10 | let handle: String 11 | } 12 | 13 | func xrpcSessionGet() async throws -> SessionGetOutput { 14 | return try await Client.shared.fetch( 15 | endpoint: "com.atproto.server.getSession", authorization: Client.shared.user.accessJwt, 16 | params: Optional.none) 17 | } 18 | -------------------------------------------------------------------------------- /swiftsky/api/atproto/sessionrefresh.swift: -------------------------------------------------------------------------------- 1 | // 2 | // sessionrefresh.swift 3 | // swiftsky 4 | // 5 | 6 | struct SessionRefreshOutput: Decodable, Hashable { 7 | let accessJwt: String 8 | let refreshJwt: String 9 | let handle: String 10 | let did: String 11 | } 12 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/actordefs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // actordefs.swift 3 | // swiftsky 4 | // 5 | 6 | struct ActorDefsSavedFeedsPref: Codable { 7 | let type = "app.bsky.actor.defs#savedFeedsPref" 8 | var pinned: [String] 9 | var saved: [String] 10 | enum CodingKeys: String, CodingKey { 11 | case type = "$type" 12 | case pinned 13 | case saved 14 | } 15 | } 16 | struct ActorDefsAdultContentPref: Codable { 17 | let type: String = "app.bsky.actor.defs#adultContentPref" 18 | let enabled: Bool 19 | enum CodingKeys: String, CodingKey { 20 | case type = "$type" 21 | case enabled 22 | } 23 | } 24 | struct ActorDefsContentLabelPref: Codable { 25 | let type = "app.bsky.actor.defs#contentLabelPref" 26 | let label: String 27 | let visibility: String 28 | enum CodingKeys: String, CodingKey { 29 | case type = "$type" 30 | case label 31 | case visibility 32 | } 33 | } 34 | 35 | struct ActorDefsProfileView: Decodable, Hashable, Identifiable { 36 | var id: String { 37 | did 38 | } 39 | let avatar: String? 40 | let description: String? 41 | let did: String 42 | let displayName: String? 43 | let handle: String 44 | let indexedAt: String? 45 | var viewer: ActorDefsViewerState? 46 | } 47 | 48 | struct ActorDefsProfileViewBasic: Decodable, Hashable, Identifiable { 49 | var id: String { 50 | did 51 | } 52 | let avatar: String? 53 | let did: String 54 | let displayName: String? 55 | let handle: String 56 | var viewer: ActorDefsViewerState? 57 | } 58 | 59 | struct ActorDefsViewerState: Decodable, Hashable { 60 | let followedBy: String? 61 | var following: String? 62 | let muted: Bool? 63 | var blocking: String? 64 | } 65 | struct ActorDefsProfileViewDetailed: Decodable, Hashable { 66 | let avatar: String? 67 | let banner: String? 68 | let description: String? 69 | let did: String 70 | let displayName: String? 71 | var followersCount: Int 72 | var followsCount: Int 73 | let handle: String 74 | let indexedAt: String? 75 | let postsCount: Int 76 | var viewer: ActorDefsViewerState? 77 | } 78 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/actorgetPreferences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // actorgetPreferences.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | enum ActorDefsPreferencesElem: Codable { 9 | case adultcontent(ActorDefsAdultContentPref) 10 | case contentlabel(ActorDefsContentLabelPref) 11 | case savedfeeds(ActorDefsSavedFeedsPref) 12 | case none 13 | enum CodingKeys: String, CodingKey { 14 | case type = "$type" 15 | } 16 | init(from decoder: Decoder) throws { 17 | let container = try decoder.container(keyedBy: CodingKeys.self) 18 | let type = try container.decode(String.self, forKey: .type) 19 | switch type { 20 | case "app.bsky.actor.defs#adultContentPref": 21 | self = try .adultcontent(.init(from: decoder)) 22 | case "app.bsky.actor.defs#contentLabelPref": 23 | self = try .contentlabel(.init(from: decoder)) 24 | case "app.bsky.actor.defs#savedFeedsPref": 25 | self = try .savedfeeds(.init(from: decoder)) 26 | default: 27 | self = .none 28 | } 29 | } 30 | func encode(to encoder: Encoder) throws { 31 | var container = encoder.singleValueContainer() 32 | switch self { 33 | case .adultcontent(let value): 34 | try container.encode(value) 35 | case .contentlabel(let value): 36 | try container.encode(value) 37 | case .savedfeeds(let value): 38 | try container.encode(value) 39 | default: 40 | break 41 | } 42 | } 43 | var feeds: ActorDefsSavedFeedsPref? { 44 | switch self { 45 | case .savedfeeds(let feeds): 46 | return feeds 47 | default: 48 | return nil 49 | } 50 | } 51 | } 52 | struct ActorGetPreferencesOutput: Decodable { 53 | let preferences: [ActorDefsPreferencesElem] 54 | } 55 | 56 | func ActorGetPreferences() async throws -> ActorGetPreferencesOutput { 57 | return try await Client.shared.fetch( 58 | endpoint: "app.bsky.actor.getPreferences", authorization: Client.shared.user.accessJwt, 59 | params: Optional.none) 60 | } 61 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/actorgetProfile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // actorprofile.swift 3 | // swiftsky 4 | // 5 | 6 | struct getProfileInput: Encodable { 7 | let actor: String 8 | } 9 | 10 | func actorgetProfile(actor: String) async throws -> ActorDefsProfileViewDetailed { 11 | return try await Client.shared.fetch( 12 | endpoint: "app.bsky.actor.getProfile", authorization: Client.shared.user.accessJwt, 13 | params: getProfileInput(actor: actor)) 14 | } 15 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/actorputPreferences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // actorputPreferences.swift 3 | // swiftsky 4 | // 5 | 6 | struct ActorPutPreferencesInput: Encodable { 7 | let preferences: [ActorDefsPreferencesElem] 8 | } 9 | 10 | func ActorPutPreferences(input: [ActorDefsPreferencesElem]) async throws -> Bool { 11 | return try await Client.shared.fetch( 12 | endpoint: "app.bsky.actor.putPreferences", httpMethod: .post, authorization: Client.shared.user.accessJwt, 13 | params: ActorPutPreferencesInput(preferences: input)) 14 | } 15 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/actorsearchActorsTypeahead.swift: -------------------------------------------------------------------------------- 1 | // 2 | // actorsearchActorsTypeahead.swift 3 | // swiftsky 4 | // 5 | 6 | struct ActorSearchActorsTypeaheadOutput: Decodable { 7 | let actors: [ActorDefsProfileViewBasic] 8 | init(actors: [ActorDefsProfileViewBasic] = []) { 9 | self.actors = actors 10 | } 11 | } 12 | struct ActorSearchActorsTypeaheadInput: Encodable { 13 | let limit: Int 14 | let term: String 15 | } 16 | func ActorSearchActorsTypeahead(limit: Int = 10, term: String) async throws -> ActorSearchActorsTypeaheadOutput { 17 | return try await Client.shared.fetch( 18 | endpoint: "app.bsky.actor.searchActorsTypeahead", authorization: Client.shared.user.accessJwt, 19 | params: ActorSearchActorsTypeaheadInput(limit: limit, term: term)) 20 | } 21 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/embedexternal.swift: -------------------------------------------------------------------------------- 1 | // 2 | // embedexternal.swift 3 | // swiftsky 4 | // 5 | 6 | struct EmbedExternalExternal: Decodable, Hashable { 7 | let description: String 8 | let thumb: LexBlob? 9 | let title: String 10 | let uri: String 11 | } 12 | struct EmbedExternalViewExternal: Decodable, Hashable { 13 | let description: String 14 | let thumb: String? 15 | let title: String 16 | let uri: String 17 | } 18 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/embedimages.swift: -------------------------------------------------------------------------------- 1 | // 2 | // embedimages.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct LexLink: Codable, Hashable { 9 | let link: String 10 | enum CodingKeys: String, CodingKey { 11 | case link = "$link" 12 | } 13 | } 14 | struct LexBlob: Codable, Hashable { 15 | let type: String? 16 | let ref: LexLink? 17 | let mimeType: String? 18 | let size: Int? 19 | enum CodingKeys: String, CodingKey { 20 | case type = "$type" 21 | case ref 22 | case mimeType 23 | case size 24 | } 25 | } 26 | struct EmbedImagesImage: Codable, Hashable { 27 | let alt: String 28 | let image: LexBlob? 29 | } 30 | struct EmbedImagesViewImage: Decodable, Hashable, Identifiable { 31 | let id = UUID() 32 | let alt: String 33 | let fullsize: String 34 | let thumb: String 35 | enum CodingKeys: CodingKey { 36 | case alt 37 | case fullsize 38 | case thumb 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/embedrecord.swift: -------------------------------------------------------------------------------- 1 | // 2 | // embedrecord.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct EmbedRecordViewRecord: Decodable, Hashable { 9 | let author: ActorDefsProfileViewBasic 10 | let cid: String 11 | let embeds: [EmbedRecordViewRecordEmbeds]? 12 | let indexedAt: Date 13 | let uri: String 14 | let value: FeedPost 15 | } 16 | struct EmbedRecordViewRecordEmbeds: Decodable, Identifiable, Hashable { 17 | let id = UUID() 18 | let type: String? 19 | let images: [EmbedImagesViewImage]? 20 | let external: EmbedExternalViewExternal? 21 | var record: EmbedRecordViewRecord? = nil 22 | enum CodingKeys:String, CodingKey { 23 | case type = "$type" 24 | case images 25 | case external 26 | case record 27 | enum recordWithMedia: CodingKey { 28 | case record 29 | } 30 | } 31 | 32 | init(from decoder: Decoder) throws { 33 | let container = try decoder.container(keyedBy: CodingKeys.self) 34 | self.type = try container.decodeIfPresent(String.self, forKey: .type) 35 | switch type { 36 | case "app.bsky.embed.record#view": 37 | let nestedcontainer = try? container.nestedContainer(keyedBy: CodingKeys.self, forKey: .record) 38 | if let nestedcontainer { 39 | let type = try nestedcontainer.decodeIfPresent(String.self, forKey: .type) 40 | if type == "app.bsky.embed.record#viewRecord" { 41 | self.record = try container.decodeIfPresent(EmbedRecordViewRecord.self, forKey: .record) 42 | } 43 | } 44 | case "app.bsky.embed.recordWithMedia#view": 45 | let ncontainer = try container.nestedContainer(keyedBy: CodingKeys.recordWithMedia.self, forKey: .record) 46 | self.record = try ncontainer.decodeIfPresent(EmbedRecordViewRecord.self, forKey: .record) 47 | default: 48 | self.record = nil 49 | } 50 | self.images = try container.decodeIfPresent([EmbedImagesViewImage].self, forKey: .images) 51 | self.external = try container.decodeIfPresent(EmbedExternalViewExternal.self, forKey: .external) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/feeddefs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // feeddefs.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct FeedDefsGeneratorViewerState: Decodable, Hashable { 9 | var like: String? 10 | } 11 | struct FeedDefsGeneratorView: Decodable, Hashable, Identifiable { 12 | var id: String { 13 | cid 14 | } 15 | let avatar: String? 16 | let cid: String 17 | let creator: ActorDefsProfileView 18 | let description: String? 19 | let descriptionFacets: [RichtextFacet]? 20 | let did: String? 21 | let displayName: String 22 | let indexedAt: String 23 | var likeCount: Int? 24 | let uri: String 25 | var viewer: FeedDefsGeneratorViewerState? 26 | } 27 | 28 | struct FeedDefsViewerState: Decodable, Hashable { 29 | var like: String? 30 | var repost: String? 31 | } 32 | struct FeedDefsPostViewEmbed: Decodable, Hashable { 33 | let type: String? 34 | var images: [EmbedImagesViewImage]? = nil 35 | var external: EmbedExternalViewExternal? = nil 36 | var record: EmbedRecordViewRecord? = nil 37 | enum CodingKeys:String, CodingKey { 38 | case type = "$type" 39 | case images 40 | case external 41 | case record 42 | case media 43 | enum recordWithMedia: CodingKey { 44 | case record 45 | case images 46 | } 47 | } 48 | 49 | init(from decoder: Decoder) throws { 50 | let container = try decoder.container(keyedBy: CodingKeys.self) 51 | self.type = try container.decodeIfPresent(String.self, forKey: .type) 52 | switch type { 53 | case "app.bsky.embed.record#view": 54 | let recordcontainer = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .record) 55 | let type = try recordcontainer.decodeIfPresent(String.self, forKey: .type) 56 | if type == "app.bsky.embed.record#viewRecord" { 57 | self.record = try container.decodeIfPresent(EmbedRecordViewRecord.self, forKey: .record) 58 | } 59 | case "app.bsky.embed.recordWithMedia#view": 60 | let recordcontainer = try container.nestedContainer(keyedBy: CodingKeys.recordWithMedia.self, forKey: .record) 61 | self.record = try recordcontainer.decodeIfPresent(EmbedRecordViewRecord.self, forKey: .record) 62 | let mediacontainer = try container.nestedContainer(keyedBy: CodingKeys.recordWithMedia.self, forKey: .media) 63 | self.images = try mediacontainer.decodeIfPresent([EmbedImagesViewImage].self, forKey: .images) 64 | case "app.bsky.embed.images#view": 65 | self.images = try container.decodeIfPresent([EmbedImagesViewImage].self, forKey: .images) 66 | default: 67 | break 68 | } 69 | self.external = try container.decodeIfPresent(EmbedExternalViewExternal.self, forKey: .external) 70 | } 71 | } 72 | struct FeedDefsPostView: Decodable, Hashable { 73 | let author: ActorDefsProfileViewBasic 74 | var cid: String 75 | let embed: FeedDefsPostViewEmbed? 76 | let indexedAt: Date 77 | var likeCount: Int 78 | let record: FeedPost 79 | let replyCount: Int 80 | var repostCount: Int 81 | let uri: String 82 | var viewer: FeedDefsViewerState 83 | } 84 | struct FeedFeedViewPostReplyRef: Decodable, Hashable { 85 | let parent: FeedDefsPostView 86 | let root: FeedDefsPostView 87 | } 88 | struct FeedDefsFeedViewPostReason: Decodable, Hashable { 89 | let by: ActorDefsProfileViewBasic 90 | let indexedAt: String 91 | } 92 | 93 | struct FeedDefsFeedViewPost: Decodable, Hashable, Identifiable { 94 | let id = UUID() 95 | var post: FeedDefsPostView 96 | let reason: FeedDefsFeedViewPostReason? 97 | let reply: FeedFeedViewPostReplyRef? 98 | init(post: FeedDefsPostView, reason: FeedDefsFeedViewPostReason? = nil, reply: FeedFeedViewPostReplyRef? = nil) { 99 | self.post = post 100 | self.reason = reason 101 | self.reply = reply 102 | } 103 | enum CodingKeys: CodingKey { 104 | case post 105 | case reason 106 | case reply 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/feedgetAuthorFeed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // feedgetAuthorFeed.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct FeedGetAuthorFeedInput: Encodable { 9 | let actor: String 10 | let limit: Int = 100 11 | let cursor: String? 12 | } 13 | 14 | struct FeedGetAuthorFeedOutput: Decodable, Identifiable { 15 | let id = UUID() 16 | var cursor: String? = "" 17 | var feed: [FeedDefsFeedViewPost] = [] 18 | enum CodingKeys: CodingKey { 19 | case cursor 20 | case feed 21 | } 22 | } 23 | 24 | func getAuthorFeed(actor: String, cursor: String? = nil) async throws 25 | -> FeedGetAuthorFeedOutput 26 | { 27 | return try await Client.shared.fetch( 28 | endpoint: "app.bsky.feed.getAuthorFeed", authorization: Client.shared.user.accessJwt, 29 | params: FeedGetAuthorFeedInput(actor: actor, cursor: cursor)) 30 | } 31 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/feedgetFeed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // feedgetFeed.swift 3 | // swiftsky 4 | // 5 | 6 | struct FeedGetFeedOutput: Decodable { 7 | let feed: [FeedDefsFeedViewPost] 8 | let cursor: String? 9 | } 10 | struct FeedGetFeedInput: Encodable { 11 | let feed: String 12 | let cursor: String? 13 | let limit: Int? 14 | } 15 | func FeedGetFeed(feed: String, cursor: String? = nil, limit: Int? = nil) async throws -> FeedGetFeedOutput { 16 | return try await Client.shared.fetch( 17 | endpoint: "app.bsky.feed.getFeed", authorization: Client.shared.user.accessJwt, 18 | params: FeedGetFeedInput(feed: feed, cursor: cursor, limit: limit)) 19 | } 20 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/feedgetFeedGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // feedgetFeedGenerator.swift 3 | // swiftsky 4 | // 5 | 6 | struct FeedGetFeedGeneratorOutput: Decodable { 7 | let view: FeedDefsGeneratorView 8 | let isOnline: Bool 9 | let isValid: Bool 10 | } 11 | func FeedGetFeedGenerator(feed: String) async throws -> FeedGetFeedGeneratorOutput { 12 | return try await Client.shared.fetch( 13 | endpoint: "app.bsky.feed.getFeedGenerator", authorization: Client.shared.user.accessJwt, 14 | params: ["feed": feed]) 15 | } 16 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/feedgetFeedGenerators.swift: -------------------------------------------------------------------------------- 1 | // 2 | // feedgetFeedGenerators.swift 3 | // swiftsky 4 | // 5 | 6 | struct FeedGetFeedGeneratorsOutput: Decodable { 7 | let feeds: [FeedDefsGeneratorView] 8 | } 9 | func FeedGetFeedGenerators(feeds: [String]) async throws -> FeedGetFeedGeneratorsOutput { 10 | return try await Client.shared.fetch( 11 | endpoint: "app.bsky.feed.getFeedGenerators", authorization: Client.shared.user.accessJwt, 12 | params: ["feeds[]": feeds]) 13 | } 14 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/feedgetLikes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // feedgetLikes.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct FeedGetLikesLike: Decodable, Identifiable { 9 | let id = UUID() 10 | let actor: ActorDefsProfileView 11 | let createdAt: String 12 | let indexedAt: String 13 | enum CodingKeys: CodingKey { 14 | case actor 15 | case createdAt 16 | case indexedAt 17 | } 18 | } 19 | struct feedgetLikesOutput: Decodable { 20 | let cid: String? 21 | var cursor: String? 22 | var likes: [FeedGetLikesLike] 23 | let uri: String 24 | init(cid: String? = nil, cursor: String? = nil, likes: [FeedGetLikesLike] = [], uri: String = "") { 25 | self.cid = cid 26 | self.cursor = cursor 27 | self.likes = likes 28 | self.uri = uri 29 | } 30 | } 31 | func feedgetLikes(cid: String, cursor: String? = nil, limit: Int = 30, uri: String) async throws -> feedgetLikesOutput { 32 | return try await Client.shared.fetch( 33 | endpoint: "app.bsky.feed.getLikes", authorization: Client.shared.user.accessJwt, 34 | params: ["cid" : cid, "cursor" : cursor, "limit": "\(limit)", "uri": uri].compactMapValues{$0}) 35 | } 36 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/feedgetPostThread.swift: -------------------------------------------------------------------------------- 1 | // 2 | // feedgetPostThread.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | class FeedGetPostThreadThreadViewPost: Decodable, Hashable, Identifiable { 9 | static func == (lhs: FeedGetPostThreadThreadViewPost, rhs: FeedGetPostThreadThreadViewPost) 10 | -> Bool 11 | { 12 | lhs.id == rhs.id 13 | } 14 | func hash(into hasher: inout Hasher) { 15 | hasher.combine(id) 16 | } 17 | let id = UUID() 18 | let type: String? 19 | let post: FeedDefsPostView? 20 | let parent: FeedGetPostThreadThreadViewPost? 21 | let replies: [FeedGetPostThreadThreadViewPost]? 22 | let notfound: Bool 23 | enum CodingKeys: String, CodingKey { 24 | case type = "$type" 25 | case post 26 | case parent 27 | case replies 28 | } 29 | required init(from decoder: Decoder) throws { 30 | let container = try decoder.container(keyedBy: CodingKeys.self) 31 | self.type = try container.decodeIfPresent(String.self, forKey: .type) 32 | self.post = try container.decodeIfPresent(FeedDefsPostView.self, forKey: .post) 33 | self.parent = decoder.codingPath.count < 100 ? try container.decodeIfPresent(FeedGetPostThreadThreadViewPost.self, forKey: .parent) : nil 34 | self.replies = try container.decodeIfPresent([FeedGetPostThreadThreadViewPost].self, forKey: .replies) 35 | self.notfound = self.type == "app.bsky.feed.defs#notFoundPost" 36 | } 37 | } 38 | struct FeedGetPostThreadInput: Encodable, Hashable { 39 | let uri: String 40 | } 41 | struct FeedGetPostThreadOutput: Decodable, Hashable { 42 | let thread: FeedGetPostThreadThreadViewPost? 43 | } 44 | 45 | func getPostThread(uri: String) async throws -> FeedGetPostThreadOutput { 46 | return try await Client.shared.fetch( 47 | endpoint: "app.bsky.feed.getPostThread", authorization: Client.shared.user.accessJwt, 48 | params: FeedGetPostThreadInput(uri: uri)) 49 | } 50 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/feedgetPosts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // feedgetPosts.swift 3 | // swiftsky 4 | // 5 | 6 | struct feedgetPostsOutput: Decodable { 7 | let posts: [FeedDefsPostView] 8 | } 9 | func feedgetPosts(uris: [String]) async throws -> feedgetPostsOutput { 10 | return try await Client.shared.fetch( 11 | endpoint: "app.bsky.feed.getPosts", authorization: Client.shared.user.accessJwt, 12 | params: ["uris[]": uris]) 13 | } 14 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/feedgetTimeline.swift: -------------------------------------------------------------------------------- 1 | // 2 | // feedgetTimeline.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct FeedGetTimelineInput: Encodable { 9 | let algorithm: String = "reverse-chronological" 10 | let limit: Int = 30 11 | let cursor: String? 12 | } 13 | struct FeedGetTimelineOutput: Decodable, Identifiable { 14 | static func == (lhs: FeedGetTimelineOutput, rhs: FeedGetTimelineOutput) -> Bool { 15 | return lhs.id == rhs.id 16 | } 17 | let id = UUID() 18 | var cursor: String? = "" 19 | var feed: [FeedDefsFeedViewPost] = [] 20 | enum CodingKeys: CodingKey { 21 | case cursor 22 | case feed 23 | } 24 | } 25 | 26 | func getTimeline(cursor: String? = nil) async throws -> FeedGetTimelineOutput { 27 | return try await Client.shared.fetch( 28 | endpoint: "app.bsky.feed.getTimeline", authorization: Client.shared.user.accessJwt, 29 | params: FeedGetTimelineInput(cursor: cursor)) 30 | } 31 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/feedpost.swift: -------------------------------------------------------------------------------- 1 | // 2 | // feedpost.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct RepoStrongRef: Codable, Hashable { 9 | let cid: String 10 | let uri: String 11 | } 12 | 13 | struct FeedPostReplyRef: Codable, Hashable { 14 | let parent: RepoStrongRef 15 | let root: RepoStrongRef 16 | } 17 | 18 | struct FeedPostTextSlice: Decodable, Hashable { 19 | let end: Int 20 | let start: Int 21 | } 22 | 23 | struct FeedPost: Decodable, Hashable { 24 | let createdAt: String 25 | let embed: FeedPostEmbed? 26 | let facets: [RichtextFacet]? 27 | let reply: FeedPostReplyRef? 28 | let text: String 29 | } 30 | 31 | struct FeedPostEmbed: Decodable, Hashable { 32 | let images: [EmbedImagesImage]? 33 | let external: EmbedExternalExternal? 34 | //let record: RepoStrongRef? 35 | } 36 | 37 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/getPopularFeedGenerators.swift: -------------------------------------------------------------------------------- 1 | // 2 | // getPopularFeedGenerators.swift 3 | // swiftsky 4 | // 5 | 6 | struct getPopularFeedGeneratorsOutput: Decodable { 7 | let feeds: [FeedDefsGeneratorView] 8 | } 9 | func getPopularFeedGenerators() async throws -> getPopularFeedGeneratorsOutput { 10 | return try await Client.shared.fetch( 11 | endpoint: "app.bsky.unspecced.getPopularFeedGenerators", authorization: Client.shared.user.accessJwt, 12 | params: Optional.none) 13 | } 14 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/graphgetFollowers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // graphgetFollowers.swift 3 | // swiftsky 4 | // 5 | 6 | struct graphGetFollowersInput: Encodable { 7 | let cursor: String? 8 | let limit: Int 9 | let actor: String 10 | } 11 | 12 | struct graphGetFollowersOutput: Decodable { 13 | var cursor: String? 14 | var followers: [ActorDefsProfileViewBasic] 15 | let subject: ActorDefsProfileViewBasic 16 | } 17 | 18 | func graphGetFollowers(actor: String, limit: Int = 30, cursor: String? = nil) async throws -> graphGetFollowersOutput { 19 | return try await Client.shared.fetch( 20 | endpoint: "app.bsky.graph.getFollowers", authorization: Client.shared.user.accessJwt, 21 | params: graphGetFollowersInput(cursor: cursor, limit: limit, actor: actor)) 22 | } 23 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/graphgetFollows.swift: -------------------------------------------------------------------------------- 1 | // 2 | // graphgetFollows.swift 3 | // swiftsky 4 | // 5 | 6 | struct graphGetFollowsInput: Encodable { 7 | let cursor: String? 8 | let limit: Int 9 | let actor: String 10 | } 11 | 12 | struct graphGetFollowsOutput: Decodable { 13 | var cursor: String? 14 | var follows: [ActorDefsProfileViewBasic] 15 | let subject: ActorDefsProfileViewBasic 16 | } 17 | 18 | func graphGetFollows(actor: String, limit: Int = 30, cursor: String? = nil) async throws -> graphGetFollowsOutput { 19 | return try await Client.shared.fetch( 20 | endpoint: "app.bsky.graph.getFollows", authorization: Client.shared.user.accessJwt, 21 | params: graphGetFollowsInput(cursor: cursor, limit: limit, actor: actor)) 22 | } 23 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/notificationlistNotifications.swift: -------------------------------------------------------------------------------- 1 | // 2 | // notificationlistNotifications.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct NotificationListNotificationsNotification: Decodable, Identifiable { 9 | var id = UUID() 10 | let author: ActorDefsProfileView? 11 | let cid: String 12 | let indexedAt: Date 13 | let isRead: Bool 14 | let reason: String 15 | let reasonSubject: String? 16 | var record: FeedPost? = nil 17 | var post: FeedDefsPostView? = nil 18 | let uri: String 19 | enum CodingKeys:String, CodingKey { 20 | case author 21 | case cid 22 | case indexedAt 23 | case isRead 24 | case reason 25 | case reasonSubject 26 | case uri 27 | case record 28 | case type = "$type" 29 | } 30 | init(from decoder: Decoder) throws { 31 | let container = try decoder.container(keyedBy: CodingKeys.self) 32 | self.author = try container.decodeIfPresent(ActorDefsProfileView.self, forKey: .author) 33 | self.cid = try container.decode(String.self, forKey: .cid) 34 | self.indexedAt = try container.decode(Date.self, forKey: .indexedAt) 35 | self.isRead = try container.decode(Bool.self, forKey: .isRead) 36 | self.reason = try container.decode(String.self, forKey: .reason) 37 | self.reasonSubject = try container.decodeIfPresent(String.self, forKey: .reasonSubject) 38 | self.uri = try container.decode(String.self, forKey: .uri) 39 | if let nestedcontainer = try? container.nestedContainer(keyedBy: CodingKeys.self, forKey: .record) { 40 | let type = try nestedcontainer.decodeIfPresent(String.self, forKey: .type) 41 | if type == "app.bsky.feed.post" { 42 | self.record = try container.decode(FeedPost.self, forKey: .record) 43 | } 44 | } 45 | } 46 | } 47 | 48 | struct NotificationListNotificationsInput: Encodable { 49 | let cursor: String? 50 | let limit: Int 51 | let seenAt: String? 52 | } 53 | struct NotificationListNotificationsOutput: Decodable { 54 | var cursor: String? 55 | var notifications: [NotificationListNotificationsNotification] 56 | } 57 | 58 | func NotificationListNotifications(limit: Int = 30, cursor: String? = nil) async throws -> NotificationListNotificationsOutput { 59 | return try await Client.shared.fetch( 60 | endpoint: "app.bsky.notification.listNotifications", authorization: Client.shared.user.accessJwt, 61 | params: NotificationListNotificationsInput(cursor: cursor, limit: limit, seenAt: nil)) 62 | } 63 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/notificationupdateSeen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // notificationupdateSeen.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | func notificationUpdateSeen() async throws -> Bool { 9 | return try await Client.shared.fetch( 10 | endpoint: "app.bsky.notification.updateSeen", httpMethod: .post, 11 | authorization: Client.shared.user.accessJwt, 12 | params: ["seenAt" : Date().iso8601withFractionalSeconds]) 13 | } 14 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/richtextfacet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // richtextfacet.swift 3 | // swiftsky 4 | // 5 | 6 | struct RichtextFacetByteSlice: Codable, Hashable { 7 | let byteEnd: Int 8 | let byteStart: Int 9 | } 10 | struct RichtextFacetFeatures: Codable, Hashable { 11 | let type: String? 12 | let uri: String? 13 | let did: String? 14 | init(type: String? = nil, uri: String? = nil, did: String? = nil) { 15 | if uri != nil { 16 | self.type = "app.bsky.richtext.facet#link" 17 | } 18 | else if did != nil { 19 | self.type = "app.bsky.richtext.facet#mention" 20 | } 21 | else { 22 | self.type = type 23 | } 24 | self.uri = uri 25 | self.did = did 26 | } 27 | enum CodingKeys: String, CodingKey { 28 | case type = "$type" 29 | case uri 30 | case did 31 | } 32 | } 33 | struct RichtextFacet: Codable, Comparable, Hashable { 34 | static func < (lhs: RichtextFacet, rhs: RichtextFacet) -> Bool { 35 | lhs.index.byteStart < rhs.index.byteStart 36 | } 37 | let type: String = "app.bsky.richtext.facet" 38 | let features: [RichtextFacetFeatures] 39 | let index: RichtextFacetByteSlice 40 | enum CodingKeys: String, CodingKey { 41 | case type = "$type" 42 | case features 43 | case index 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/systemdeclRef.swift: -------------------------------------------------------------------------------- 1 | // 2 | // systemdeclRef.swift 3 | // swiftsky 4 | // 5 | 6 | struct SystemDeclRef: Decodable, Hashable { 7 | let actorType: String 8 | let cid: String 9 | 10 | init(actorType: String = "", cid: String = "") { 11 | self.actorType = actorType 12 | self.cid = cid 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /swiftsky/api/bsky/unspeccedgetPopular.swift: -------------------------------------------------------------------------------- 1 | // 2 | // unspeccedgetPopular.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct UnspeccedGetPopularInput: Encodable { 9 | let limit: Int 10 | let cursor: String? 11 | } 12 | 13 | struct UnspeccedGetPopularOutput: Decodable, Identifiable { 14 | static func == (lhs: UnspeccedGetPopularOutput, rhs: UnspeccedGetPopularOutput) -> Bool { 15 | return lhs.id == rhs.id 16 | } 17 | let id = UUID() 18 | var cursor: String? = "" 19 | var feed: [FeedDefsFeedViewPost] = [] 20 | enum CodingKeys: CodingKey { 21 | case cursor 22 | case feed 23 | } 24 | } 25 | 26 | func getPopular(cursor: String? = nil, limit: Int = 100) async throws -> UnspeccedGetPopularOutput { 27 | return try await Client.shared.fetch( 28 | endpoint: "app.bsky.unspecced.getPopular", authorization: Client.shared.user.accessJwt, 29 | params: UnspeccedGetPopularInput(limit: limit, cursor: cursor)) 30 | } 31 | -------------------------------------------------------------------------------- /swiftsky/api/gtranslate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // gtranslate.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct GoogleTranslate { 9 | static func translate(text: String, from: String = "auto", to: String) async throws -> String { 10 | var urlComponents = URLComponents(string: "https://translate.googleapis.com/translate_a/t")! 11 | urlComponents.queryItems = [ 12 | URLQueryItem(name: "client", value: "dict-chrome-ex"), 13 | URLQueryItem(name: "sl", value: from), 14 | URLQueryItem(name: "tl", value: to), 15 | URLQueryItem(name: "q", value: text), 16 | ] 17 | guard let url = urlComponents.url else { 18 | throw URLError(.badURL) 19 | } 20 | let request = URLRequest(url: url) 21 | let response = try await URLSession.shared.data(for: request) 22 | guard let object = try? JSONSerialization.jsonObject(with: response.0, options: []) else { 23 | throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey : "Decode error: 0"]) 24 | } 25 | guard let array = object as? [[String]] else { 26 | throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey : "Decode error: 1"]) 27 | } 28 | return array[0][0] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /swiftsky/api/keychain.swift: -------------------------------------------------------------------------------- 1 | // 2 | // keychain.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct Keychain { 9 | public static func get(_ key: String) -> Data? { 10 | let query: [CFString: Any] = 11 | [ 12 | kSecClass : kSecClassGenericPassword, 13 | kSecAttrAccount: key, 14 | kSecReturnData : true, 15 | ] 16 | var data: AnyObject? 17 | SecItemCopyMatching(query as CFDictionary, &data) 18 | return data as? Data 19 | } 20 | public static func set(_ value: Data, _ key: String) { 21 | let query: [CFString : Any] = [ 22 | kSecClass : kSecClassGenericPassword, 23 | kSecAttrAccount : key, 24 | kSecValueData : value 25 | ] 26 | delete(key) 27 | SecItemAdd(query as CFDictionary, nil) 28 | } 29 | public static func delete(_ key: String) { 30 | let query: [CFString : Any] = [ 31 | kSecClass : kSecClassGenericPassword, 32 | kSecAttrAccount : key, 33 | ] 34 | SecItemDelete(query as CFDictionary) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /swiftsky/api/richtext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // richtext.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | struct RichTextSegment: Identifiable { 9 | let id = UUID() 10 | let text: String 11 | var facet: RichtextFacet? = nil 12 | 13 | func link() -> String? { 14 | facet?.features.first(where: {$0.uri != nil})?.uri 15 | } 16 | func mention() -> String? { 17 | facet?.features.first(where: {$0.did != nil})?.did 18 | } 19 | } 20 | struct RichText { 21 | let text: String 22 | let facets: [RichtextFacet]? 23 | func detectFacets() async -> [RichtextFacet] { 24 | var facets: [RichtextFacet] = [] 25 | let mentionmatches = try? NSRegularExpression(pattern: "(^|\\s|\\()(@)([a-zA-Z0-9.-]+)(\\b)", options: []) 26 | .matches(in: self.text, range: NSRange(location: 0, length: self.text.utf16.count)) 27 | let urlmatches = try? NSRegularExpression(pattern: "(^|\\s|\\()((https?:\\/\\/[\\S]+)|((?[a-z][a-z0-9]*(\\.[a-z0-9]+)+)[\\S]*))", options: []) 28 | .matches(in: self.text, range: NSRange(location: 0, length: self.text.utf16.count)) 29 | if let urlmatches { 30 | for match in urlmatches { 31 | if let range = Range(match.range(at: 2), in: self.text) { 32 | var url = self.text[range] 33 | if !url.starts(with: "http") { 34 | url = "https://\(url)" 35 | } 36 | let facet = RichtextFacet(features: [RichtextFacetFeatures(type: "app.bsky.richtext.facet#link", uri: String(url))], index: RichtextFacetByteSlice(byteEnd: text.utf8.distance(from: text.startIndex, to: range.upperBound), byteStart: text.utf8.distance(from: text.startIndex, to: range.lowerBound))) 37 | facets.append(facet) 38 | } 39 | } 40 | } 41 | 42 | if let mentionmatches { 43 | for match in mentionmatches{ 44 | if let range = Range(match.range(at: 3), in: self.text) { 45 | let handle = self.text[range] 46 | guard let did = try? await IdentityResolveHandle(handle: String(handle)) else { 47 | continue 48 | } 49 | let facet = RichtextFacet(features: [RichtextFacetFeatures(type: "app.bsky.richtext.facet#mention", did: did.did)], index: RichtextFacetByteSlice(byteEnd: text.utf8.distance(from: text.startIndex, to: range.upperBound), byteStart: text.utf8.distance(from: text.startIndex, to: range.lowerBound) - 1)) 50 | facets.append(facet) 51 | } 52 | } 53 | } 54 | return facets 55 | } 56 | func segments() -> [RichTextSegment] { 57 | var segments: [RichTextSegment] = [] 58 | var facets = self.facets ?? [] 59 | if facets.count == 0 { 60 | segments.append(RichTextSegment(text: self.text)) 61 | return segments 62 | } 63 | facets.sort() 64 | var textCursor = 0 65 | var facetCursor = 0 66 | repeat { 67 | let currFacet = facets[facetCursor] 68 | if (textCursor < currFacet.index.byteStart) { 69 | segments.append(RichTextSegment(text: self.text[textCursor.. currFacet.index.byteStart { 72 | facetCursor += 1 73 | continue 74 | } 75 | if (currFacet.index.byteStart < currFacet.index.byteEnd) { 76 | let subtext = self.text[currFacet.index.byteStart.. AtUri { 31 | if let match = try? ATP_URI_REGEX.wholeMatch(in: str) { 32 | return AtUri(hash: "\(match.5 ?? "")", host: "\(match.2)", pathname: "\(match.3 ?? "")") 33 | } 34 | return AtUri(hash: "", host: "", pathname: "") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /swiftsky/extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // extensions.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | import NaturalLanguage 8 | import SwiftUI 9 | 10 | extension View { 11 | func hoverHand(callback: ((Bool) -> ())? = nil) -> some View { 12 | self 13 | .onHover { 14 | if $0 { 15 | NSCursor.pointingHand.push() 16 | } 17 | else { 18 | NSCursor.pop() 19 | } 20 | callback?($0) 21 | } 22 | } 23 | } 24 | 25 | extension ISO8601DateFormatter { 26 | convenience init(_ formatOptions: Options) { 27 | self.init() 28 | self.formatOptions = formatOptions 29 | } 30 | } 31 | extension RelativeDateTimeFormatter { 32 | convenience init(_ dateTimeStyle: DateTimeStyle) { 33 | self.init() 34 | self.dateTimeStyle = dateTimeStyle 35 | } 36 | } 37 | extension Formatter { 38 | static let iso8601withFractionalSeconds = ISO8601DateFormatter([ 39 | .withInternetDateTime, .withFractionalSeconds, 40 | ]) 41 | static let iso8601withTimeZone = ISO8601DateFormatter([.withInternetDateTime, .withTimeZone]) 42 | static let relativeDateNamed = RelativeDateTimeFormatter(.named) 43 | } 44 | extension Date { 45 | var iso8601withFractionalSeconds: String { 46 | return Formatter.iso8601withFractionalSeconds.string(from: self) 47 | } 48 | } 49 | extension Collection { 50 | subscript(safe index: Index) -> Element? { 51 | return indices.contains(index) ? self[index] : nil 52 | } 53 | } 54 | extension Locale { 55 | static var preferredLanguageCodes: [String] { 56 | return Locale.preferredLanguages.compactMap({ 57 | Locale(identifier: $0).language.languageCode?.identifier 58 | }) 59 | } 60 | } 61 | extension String { 62 | var languageCode: String { 63 | let recognizer = NLLanguageRecognizer() 64 | recognizer.processString(self) 65 | guard let languageCode = recognizer.dominantLanguage?.rawValue else { return "" } 66 | return languageCode 67 | } 68 | } 69 | class NSAction: NSObject { 70 | let action: (T) -> Void 71 | 72 | init(_ action: @escaping (T) -> Void) { 73 | self.action = action 74 | } 75 | 76 | @objc func invoke(sender: AnyObject) { 77 | action(sender as! T) 78 | } 79 | } 80 | 81 | extension NSButton { 82 | func setAction(_ closure: @escaping (NSButton) -> Void) { 83 | let action = NSAction(closure) 84 | self.target = action 85 | self.action = #selector(NSAction.invoke) 86 | objc_setAssociatedObject( 87 | self, "\(self.hashValue)", action, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) 88 | } 89 | } 90 | extension NSMenuItem { 91 | func setAction(_ closure: @escaping (NSMenuItem) -> Void) { 92 | let action = NSAction(closure) 93 | self.target = action 94 | self.action = #selector(NSAction.invoke) 95 | objc_setAssociatedObject( 96 | self, "\(self.hashValue)", action, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN) 97 | } 98 | } 99 | 100 | extension Encodable { 101 | var dictionary: [String: Any]? { 102 | guard let data = try? JSONEncoder().encode(self) else { return nil } 103 | return (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)).flatMap { 104 | $0 as? [String: Any] 105 | } 106 | } 107 | } 108 | 109 | extension String { 110 | subscript (bounds: CountableRange) -> String { 111 | if bounds.upperBound > self.utf8.count { 112 | return "" 113 | } 114 | let start = self.utf8.index(startIndex, offsetBy: bounds.lowerBound) 115 | let end = self.utf8.index(startIndex, offsetBy: bounds.upperBound) 116 | return String(self.utf8[start.. Bool { 11 | lhs.id == rhs.id 12 | } 13 | func hash(into hasher: inout Hasher) { 14 | hasher.combine(id) 15 | } 16 | var id: String { 17 | uri 18 | } 19 | var data: FeedDefsGeneratorView 20 | var uri: String { 21 | data.uri 22 | } 23 | var avatar: String? { 24 | data.avatar 25 | } 26 | var displayName: String { 27 | data.displayName 28 | } 29 | init(data: FeedDefsGeneratorView) { 30 | self.data = data 31 | } 32 | } 33 | class SavedFeedsModel { 34 | static var shared = SavedFeedsModel() 35 | var feedModelCache = NSCache() 36 | func updateCache() async throws { 37 | let newFeedModels = NSCache() 38 | var neededFeedUris: [String] = [] 39 | for feedUri in PreferencesModel.shared.savedFeeds { 40 | if !newFeedModels.doesContain(feedUri) { 41 | neededFeedUris.append(feedUri) 42 | } 43 | } 44 | for i in stride(from: 0, to: neededFeedUris.count, by: 25) { 45 | let res = try await FeedGetFeedGenerators(feeds: Array(neededFeedUris[i.. CustomFeedModel? { 53 | feedModelCache.object(forKey: uri as NSString) 54 | } 55 | var pinned: [CustomFeedModel] { 56 | PreferencesModel.shared.pinnedFeeds.compactMap { uri in 57 | feedModelCache.object(forKey: uri as NSString) 58 | } 59 | } 60 | } 61 | class PreferencesModel: ObservableObject { 62 | static var shared = PreferencesModel() 63 | @Published var savedFeeds: [String] = [] 64 | @Published var pinnedFeeds: [String] = [] 65 | func update(cb: @escaping ([ActorDefsPreferencesElem]) -> ([ActorDefsPreferencesElem]?)) async throws { 66 | let res = try await ActorGetPreferences() 67 | if let newPrefs = cb(res.preferences) { 68 | let _ = try await ActorPutPreferences(input: newPrefs) 69 | } 70 | } 71 | func sync() async throws { 72 | let res = try await ActorGetPreferences().preferences 73 | for pref in res { 74 | switch pref { 75 | case .savedfeeds(let feeds): 76 | DispatchQueue.main.async { 77 | self.savedFeeds = feeds.saved 78 | self.pinnedFeeds = feeds.pinned 79 | } 80 | default: 81 | break 82 | } 83 | } 84 | } 85 | func setSavedFeeds(saved: [String], pinned: [String]) async { 86 | let oldSaved = savedFeeds 87 | let oldPinned = pinnedFeeds 88 | DispatchQueue.main.async { 89 | self.savedFeeds = saved 90 | self.pinnedFeeds = pinned 91 | } 92 | do { 93 | try await update { prefs in 94 | let feedsPref = prefs.first(where: {if case ActorDefsPreferencesElem.savedfeeds = $0 { 95 | return true 96 | } 97 | return false 98 | }) 99 | var prefsfiltered = prefs.filter { 100 | if case ActorDefsPreferencesElem.savedfeeds = $0 { 101 | return false 102 | } 103 | return true 104 | } 105 | if var feeds = feedsPref?.feeds { 106 | feeds.saved = saved 107 | feeds.pinned = pinned 108 | prefsfiltered.append(ActorDefsPreferencesElem.savedfeeds(feeds)) 109 | } 110 | 111 | return prefsfiltered 112 | } 113 | } catch { 114 | DispatchQueue.main.async { 115 | self.savedFeeds = oldSaved 116 | self.pinnedFeeds = oldPinned 117 | } 118 | print(error) 119 | } 120 | } 121 | func deletefeed(uri: String) async { 122 | var pinned = pinnedFeeds 123 | var saved = savedFeeds 124 | if let pindex = pinned.firstIndex(where: {$0 == uri}) { 125 | pinned.remove(at: pindex) 126 | } 127 | if let sindex = saved.firstIndex(where: {$0 == uri}) { 128 | saved.remove(at: sindex) 129 | } 130 | await setSavedFeeds(saved: saved, pinned: pinned) 131 | } 132 | func unpinfeed(uri: String) async { 133 | var pinned = pinnedFeeds 134 | if let pindex = pinned.firstIndex(where: {$0 == uri}) { 135 | pinned.remove(at: pindex) 136 | } 137 | await setSavedFeeds(saved: savedFeeds, pinned: pinned) 138 | } 139 | func addsavedfeed(uri: String) async { 140 | await setSavedFeeds(saved: savedFeeds + [uri], pinned: pinnedFeeds) 141 | } 142 | func addpinnedfeed(uri: String) async { 143 | await setSavedFeeds(saved: savedFeeds, pinned: pinnedFeeds + [uri]) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /swiftsky/models/PushNotifications.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PushNotifications.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | class PushNotificatios: ObservableObject { 9 | static let shared = PushNotificatios() 10 | var unreadcount: Int = 0 11 | private var backgroundtask: Task? 12 | public func resumeRefreshTask() { 13 | self.backgroundtask?.cancel() 14 | self.backgroundtask = Task { 15 | while !Task.isCancelled { 16 | if Auth.shared.needAuthorization { 17 | break 18 | } 19 | if let notifications = try? await NotificationListNotifications().notifications { 20 | let unreadnotifications = notifications.filter { 21 | !$0.isRead 22 | } 23 | self.setunreadCount(unreadnotifications.count) 24 | } 25 | try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) 26 | } 27 | } 28 | } 29 | public func cancelRefreshTask() { 30 | self.backgroundtask?.cancel() 31 | } 32 | public func setunreadCount(_ value: Int) { 33 | if self.unreadcount != value { 34 | self.unreadcount = value 35 | DispatchQueue.main.async { 36 | self.objectWillChange.send() 37 | } 38 | } 39 | } 40 | public func markasRead() { 41 | self.setunreadCount(0) 42 | Task { 43 | try? await notificationUpdateSeen() 44 | } 45 | } 46 | init() { 47 | if !Auth.shared.needAuthorization { 48 | resumeRefreshTask() 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /swiftsky/models/TranslateViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThreadViewModel.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | 8 | class TranslateViewModel: ObservableObject { 9 | var text: String = "" 10 | func translatetext() { 11 | if translatedtext.isEmpty { 12 | Task { 13 | do { 14 | DispatchQueue.main.async { 15 | self.translatestatus = 1 16 | } 17 | let translatedtext = try await GoogleTranslate.translate(text: self.text, to: GlobalViewModel.shared.systemLanguageCode) 18 | DispatchQueue.main.async { 19 | self.translatedtext = translatedtext 20 | self.translatestatus = 2 21 | self.showtranslated = true 22 | } 23 | } catch { 24 | DispatchQueue.main.async { 25 | self.translatestatus = 0 26 | self.error = error.localizedDescription 27 | } 28 | } 29 | } 30 | } 31 | else { 32 | self.showtranslated.toggle() 33 | } 34 | } 35 | @Published var translatedtext = "" 36 | @Published var showtranslated = false 37 | @Published var translatestatus = 0 38 | @Published var error = "" 39 | } 40 | -------------------------------------------------------------------------------- /swiftsky/swiftsky.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /swiftsky/swiftskyApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // swiftskyApp.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | @main 9 | struct swiftskyApp: App { 10 | @StateObject private var auth = Auth.shared 11 | @StateObject private var globalviewmodel = GlobalViewModel.shared 12 | @StateObject private var pushnotifications = PushNotificatios.shared 13 | @StateObject private var preferences = PreferencesModel.shared 14 | init() { 15 | Client.shared.postInit() 16 | GlobalViewModel.shared.systemLanguageCode = Locale.preferredLanguageCodes[0] 17 | GlobalViewModel.shared.systemLanguage = Locale.current.localizedString(forLanguageCode: GlobalViewModel.shared.systemLanguageCode) ?? "en" 18 | } 19 | var body: some Scene { 20 | WindowGroup { 21 | SidebarView().sheet(isPresented: $auth.needAuthorization) { 22 | LoginView() 23 | } 24 | .environmentObject(auth) 25 | .environmentObject(globalviewmodel) 26 | .environmentObject(pushnotifications) 27 | .environmentObject(preferences) 28 | } 29 | .defaultSize(width: 1100, height: 650) 30 | .commands { 31 | CommandMenu("Account") { 32 | if let profile = globalviewmodel.profile { 33 | Text("@\(profile.handle)") 34 | Button("Sign out") { 35 | auth.signout() 36 | } 37 | } 38 | } 39 | } 40 | .onChange(of: auth.needAuthorization) { 41 | if !$0 { 42 | pushnotifications.resumeRefreshTask() 43 | Task { 44 | self.globalviewmodel.profile = try? await actorgetProfile(actor: Client.shared.handle) 45 | } 46 | } 47 | else { 48 | pushnotifications.cancelRefreshTask() 49 | } 50 | } 51 | Settings { 52 | SettingsView() 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /swiftsky/views/AvatarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AvatarView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct AvatarView: View { 9 | let url: String? 10 | let size: CGFloat 11 | let blur: Bool 12 | let isFeed: Bool 13 | init(url: String?, size: CGFloat, blur: Bool = false, isFeed: Bool = false) { 14 | self.url = url 15 | self.size = size 16 | self.blur = blur 17 | self.isFeed = isFeed 18 | } 19 | var body: some View { 20 | if let url { 21 | AsyncImage(url: URL(string: url)) { image in 22 | image 23 | .resizable() 24 | .blur(radius: blur ? 15 : 0, opaque: true) 25 | .aspectRatio(contentMode: .fill) 26 | .frame(width: size, height: size) 27 | .clipped() 28 | } placeholder: { 29 | ProgressView() 30 | .frame(width: size, height: size) 31 | } 32 | .cornerRadius(isFeed ? size / 4 : size / 2) 33 | } 34 | else { 35 | if isFeed { 36 | ZStack { 37 | RoundedRectangle(cornerSize: CGSize(width: size / 4, height: size / 4)) 38 | .frame(width: size, height: size) 39 | .foregroundStyle(Color(red: 0.0, green: 0.439, blue: 1.0)) 40 | Image("default-feed") 41 | .resizable() 42 | .foregroundStyle(.white) 43 | .frame(width: size / 1.5, height: size / 1.5) 44 | } 45 | } 46 | else { 47 | Image(systemName: "person.crop.circle.fill") 48 | .resizable() 49 | .foregroundStyle(.white, Color.accentColor) 50 | .frame(width: size, height: size) 51 | .cornerRadius(isFeed ? size / 4 : size / 2) 52 | } 53 | 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /swiftsky/views/DiscoverFeedsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiscoverFeedsView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct FeedRowView: View { 9 | let feed: FeedDefsGeneratorView 10 | @EnvironmentObject private var preferences: PreferencesModel 11 | var isSaved: Bool { 12 | preferences.savedFeeds.contains(where: { 13 | feed.uri == $0 14 | }) 15 | } 16 | var isPinned: Bool { 17 | preferences.pinnedFeeds.contains(where: { 18 | feed.uri == $0 19 | }) 20 | } 21 | var body: some View { 22 | VStack(alignment: .leading, spacing: 0) { 23 | HStack(alignment: .top) { 24 | AvatarView(url: feed.avatar, size: 40, isFeed: true) 25 | VStack(alignment: .leading, spacing: 0) { 26 | Text(feed.displayName) 27 | Text("by @\(feed.creator.handle)") 28 | .foregroundStyle(.secondary) 29 | } 30 | Spacer() 31 | HStack { 32 | let saved = isSaved 33 | let pinned = isPinned 34 | if saved { 35 | Button { 36 | Task { 37 | if pinned { 38 | await preferences.unpinfeed(uri: feed.uri) 39 | } 40 | else { 41 | await preferences.addpinnedfeed(uri: feed.uri) 42 | } 43 | } 44 | } label: { 45 | Image(systemName: "pin") 46 | .foregroundStyle(pinned ? Color.accentColor : .secondary) 47 | } 48 | .buttonStyle(.plain) 49 | .font(.system(size: 18)) 50 | .padding(.trailing, 5) 51 | } 52 | 53 | Button { 54 | Task { 55 | if saved { 56 | await preferences.deletefeed(uri: feed.uri) 57 | } 58 | else { 59 | SavedFeedsModel.shared.feedModelCache.setObject(CustomFeedModel(data: feed), forKey: feed.uri as NSString) 60 | await preferences.addsavedfeed(uri: feed.uri) 61 | } 62 | } 63 | } label: { 64 | Image(systemName: saved ? "trash" : "plus") 65 | .foregroundStyle(saved ? .secondary : Color.accentColor) 66 | } 67 | .buttonStyle(.plain) 68 | .font(.system(size: 18)) 69 | .padding(.trailing, 5) 70 | } 71 | 72 | } 73 | if let description = feed.description { 74 | Text(description) 75 | .padding(.top, 10) 76 | } 77 | if let likeCount = feed.likeCount { 78 | Text("Liked by \(likeCount) users") 79 | .padding(.top, 5) 80 | .foregroundStyle(.secondary) 81 | } 82 | 83 | } 84 | 85 | 86 | } 87 | } 88 | struct DiscoverFeedsView: View { 89 | @State var feeds: [FeedDefsGeneratorView] = [] 90 | @State var isLoading = false 91 | @Binding var path: [Navigation] 92 | func loadContent() async { 93 | isLoading = true 94 | do { 95 | let feeds = try await getPopularFeedGenerators() 96 | self.feeds = feeds.feeds 97 | } catch { 98 | print(error) 99 | } 100 | isLoading = false 101 | } 102 | var body: some View { 103 | List { 104 | Group { 105 | if isLoading { 106 | ProgressView().frame(maxWidth: .infinity, alignment: .center) 107 | } 108 | ForEach(feeds) { feed in 109 | FeedRowView(feed: feed) 110 | .padding(.vertical, 10) 111 | .contentShape(Rectangle()) 112 | .onTapGesture { 113 | path.append(.feed(.init(data: feed))) 114 | } 115 | Divider() 116 | } 117 | } 118 | .listRowInsets(EdgeInsets()) 119 | .listRowSeparator(.hidden) 120 | } 121 | .environment(\.defaultMinListRowHeight, 1) 122 | .scrollContentBackground(.hidden) 123 | .listStyle(.plain) 124 | .task { 125 | await loadContent() 126 | } 127 | } 128 | } 129 | 130 | -------------------------------------------------------------------------------- /swiftsky/views/EmbedExternalView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmbedExternalView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct EmbedExternalView: View { 9 | @State var record: EmbedExternalViewExternal 10 | @Environment(\.openURL) private var openURL 11 | var body: some View { 12 | Button { 13 | if let url = URL(string: record.uri) { 14 | openURL(url) 15 | } 16 | } label: { 17 | ZStack(alignment: .topLeading) { 18 | 19 | RoundedRectangle(cornerRadius: 10) 20 | .frame(maxWidth: .infinity) 21 | .opacity(0.02) 22 | 23 | VStack(alignment: .leading) { 24 | if let thumb = record.thumb { 25 | AsyncImage(url: URL(string: thumb)) { 26 | $0 27 | .resizable() 28 | .aspectRatio(contentMode: .fill) 29 | .frame(width: 400, height: 200) 30 | .clipped() 31 | } placeholder: { 32 | ProgressView() 33 | .frame(width: 400, height: 200) 34 | } 35 | } 36 | if !record.title.isEmpty { 37 | Text(record.title) 38 | } 39 | Text(record.uri) 40 | .foregroundColor(.secondary) 41 | if !record.description.isEmpty { 42 | Text(record.description) 43 | .lineLimit(2) 44 | } 45 | } 46 | .padding(10) 47 | } 48 | .fixedSize(horizontal: false, vertical: true) 49 | .padding(.bottom, 5) 50 | .frame(maxWidth: 400) 51 | } 52 | .buttonStyle(.plain) 53 | .contentShape(Rectangle()) 54 | 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /swiftsky/views/EmbedPostView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmbedPostView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct EmbedPostView: View { 9 | @State var embedrecord: EmbedRecordViewRecord 10 | @State var usernamehover: Bool = false 11 | @Binding var path: [Navigation] 12 | var markdown: String { 13 | var markdown = String() 14 | let rt = RichText(text: embedrecord.value.text, facets: embedrecord.value.facets) 15 | for segment in rt.segments() { 16 | if let link = segment.link() { 17 | markdown += "[\(segment.text)](\(link))" 18 | } 19 | else if let mention = segment.mention() { 20 | markdown += "[\(segment.text)](swiftsky://profile?did=\(mention))" 21 | } 22 | else { 23 | markdown += segment.text 24 | } 25 | } 26 | return markdown 27 | } 28 | var body: some View { 29 | ZStack(alignment: .topLeading) { 30 | 31 | RoundedRectangle(cornerRadius: 10) 32 | .frame(maxWidth: .infinity) 33 | .opacity(0.02) 34 | 35 | VStack(alignment: .leading, spacing: 0) { 36 | HStack(alignment: .top) { 37 | AvatarView(url: embedrecord.author.avatar, size: 16) 38 | let displayname = embedrecord.author.displayName ?? embedrecord.author.handle 39 | 40 | Button { 41 | path.append(.profile(embedrecord.author.did)) 42 | } label: { 43 | Text("\(displayname) \(Text(embedrecord.author.handle).foregroundColor(.secondary))") 44 | .fontWeight(.semibold) 45 | .underline(usernamehover) 46 | } 47 | .buttonStyle(.plain) 48 | .hoverHand {usernamehover = $0} 49 | .tooltip { 50 | ProfilePreview(did: embedrecord.author.did, path: $path) 51 | } 52 | Text( 53 | Formatter.relativeDateNamed.localizedString( 54 | fromTimeInterval: embedrecord.indexedAt.timeIntervalSinceNow) 55 | ) 56 | .foregroundColor(.secondary) 57 | } 58 | 59 | Text(.init(markdown)) 60 | if let embed = embedrecord.embeds { 61 | ForEach(embed) { embed in 62 | if let external = embed.external { 63 | EmbedExternalView(record: external) 64 | } 65 | if let images = embed.images { 66 | HStack { 67 | ForEach(images) { image in 68 | Button { 69 | GlobalViewModel.shared.preview = URL(string: image.fullsize) 70 | } label: { 71 | let imagewidth = 600.0 / Double(images.count) 72 | let imageheight = 600.0 / Double(images.count) 73 | AsyncImage(url: URL(string: image.thumb)) { image in 74 | image 75 | .resizable() 76 | .aspectRatio(contentMode: .fit) 77 | .frame(maxWidth: imagewidth, maxHeight: imageheight, alignment: .topLeading) 78 | .clipped() 79 | 80 | } placeholder: { 81 | ProgressView() 82 | .frame(width: imagewidth, height: imageheight) 83 | } 84 | .padding(.init(top: 5, leading: 0, bottom: 0, trailing: 0)) 85 | .cornerRadius(15) 86 | } 87 | .buttonStyle(.plain) 88 | } 89 | } 90 | } 91 | } 92 | } 93 | } 94 | .padding(10) 95 | 96 | } 97 | .fixedSize(horizontal: false, vertical: true) 98 | .padding(.bottom, 5) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /swiftsky/views/ErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct ErrorView: View { 9 | let error: String 10 | var action: () -> () 11 | var body: some View { 12 | Group { 13 | Text(error) 14 | .multilineTextAlignment(.center) 15 | .lineLimit(nil) 16 | Button("\(Image(systemName: "arrow.clockwise")) Retry") { 17 | action() 18 | } 19 | .buttonStyle(.borderedProminent) 20 | .controlSize(.large) 21 | } 22 | .frame(maxWidth: .infinity, alignment: .center) 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /swiftsky/views/FeedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct FeedView: View { 9 | var model: CustomFeedModel 10 | let header: Bool 11 | @State var feedview: FeedDefsGeneratorView? = nil 12 | @State var feed: [FeedDefsFeedViewPost] = [] 13 | @State var cursor: String? = nil 14 | @State var isLoading = false 15 | @State var isLikeDisabled = false 16 | @Binding var path: [Navigation] 17 | @EnvironmentObject private var preferences: PreferencesModel 18 | func loadContent() async { 19 | isLoading = true 20 | do { 21 | if header { 22 | self.feedview = try await FeedGetFeedGenerator(feed: model.uri).view 23 | } 24 | let feed = try await FeedGetFeed(feed: model.uri, cursor: cursor) 25 | cursor = feed.cursor 26 | if self.feed.isEmpty { 27 | self.feed = feed.feed 28 | } 29 | else { 30 | self.feed.append(contentsOf: feed.feed) 31 | } 32 | } catch { 33 | print(error) 34 | } 35 | isLoading = false 36 | } 37 | var isSaved: Bool { 38 | preferences.savedFeeds.contains(where: { 39 | model.uri == $0 40 | }) 41 | } 42 | var isPinned: Bool { 43 | preferences.pinnedFeeds.contains(where: { 44 | model.uri == $0 45 | }) 46 | } 47 | func like() { 48 | Task { 49 | do { 50 | let result = try await likePost(uri: model.uri, cid: model.data.cid) 51 | feedview!.viewer!.like = result.uri 52 | feedview!.likeCount! += 1 53 | } catch { 54 | 55 | } 56 | isLikeDisabled = false 57 | } 58 | } 59 | func unlike() { 60 | let like = feedview!.viewer!.like 61 | Task { 62 | do { 63 | if try await repoDeleteRecord(uri: like!, collection: "app.bsky.feed.like") { 64 | feedview!.viewer!.like = nil 65 | feedview!.likeCount! -= 1 66 | } 67 | } catch { 68 | 69 | } 70 | isLikeDisabled = false 71 | } 72 | } 73 | @ViewBuilder var feedheader: some View { 74 | if header, let feedview = self.feedview { 75 | HStack(alignment: .top, spacing: 0) { 76 | AvatarView(url: feedview.avatar, size: 80, isFeed: true) 77 | .padding() 78 | VStack(alignment: .leading, spacing: 0) { 79 | Text(feedview.displayName) 80 | .font(.title) 81 | Text("by @\(feedview.creator.handle)") 82 | .foregroundStyle(.secondary) 83 | 84 | if let description = feedview.description { 85 | Text(description) 86 | .padding(.top, 5) 87 | } 88 | let saved = isSaved 89 | let pinned = isPinned 90 | HStack { 91 | Button { 92 | Task { 93 | if saved { 94 | await preferences.deletefeed(uri: feedview.uri) 95 | } 96 | else { 97 | SavedFeedsModel.shared.feedModelCache.setObject(CustomFeedModel(data: feedview), forKey: feedview.uri as NSString) 98 | await preferences.addsavedfeed(uri: feedview.uri) 99 | } 100 | } 101 | } label: { 102 | Text(saved ? "Remove from My Feeds" : "Add to My Feeds") 103 | .font(.headline) 104 | .fontWeight(.bold) 105 | .padding(.horizontal, 10) 106 | .padding(.vertical, 5) 107 | } 108 | .buttonStyle(.borderedProminent) 109 | .tint(saved ? .secondary : .accentColor) 110 | .clipShape(Capsule()) 111 | Button("\(Image(systemName: "pin"))") { 112 | Task { 113 | if pinned { 114 | await preferences.unpinfeed(uri: model.uri) 115 | } 116 | else { 117 | await preferences.addpinnedfeed(uri: model.uri) 118 | } 119 | } 120 | } 121 | .buttonStyle(.plain) 122 | .disabled(!saved) 123 | .foregroundStyle(pinned ? Color.accentColor : .primary) 124 | Button("\(Image(systemName: "hand.thumbsup")) \(feedview.likeCount ?? 0)") { 125 | if !isLikeDisabled { 126 | isLikeDisabled = true 127 | if feedview.viewer!.like == nil { 128 | like() 129 | } else { 130 | unlike() 131 | } 132 | } 133 | } 134 | .buttonStyle(.plain) 135 | .disabled(isLikeDisabled) 136 | .foregroundStyle(feedview.viewer!.like != nil ? .pink : .primary) 137 | } 138 | .padding(.top, 5) 139 | 140 | } 141 | .padding(.top) 142 | } 143 | .padding(.horizontal) 144 | .textSelection(.enabled) 145 | Divider() 146 | .padding(.top, 5) 147 | } 148 | } 149 | var body: some View { 150 | List { 151 | Group { 152 | if isLoading { 153 | ProgressView().frame(maxWidth: .infinity, alignment: .center) 154 | } 155 | feedheader 156 | ForEach(feed) { post in 157 | PostView( 158 | post: post.post, reply: post.reply?.parent.author.handle, repost: post.reason, 159 | path: $path 160 | ) 161 | .padding([.top, .horizontal]) 162 | .contentShape(Rectangle()) 163 | .onTapGesture { 164 | path.append(.thread(post.post.uri)) 165 | } 166 | .task { 167 | if post == feed.last && !isLoading && cursor != nil { 168 | await loadContent() 169 | } 170 | } 171 | PostFooterView(post: post.post, path: $path) 172 | Divider() 173 | } 174 | if cursor != nil { 175 | ProgressView().frame(maxWidth: .infinity, alignment: .center) 176 | } 177 | } 178 | .listRowInsets(EdgeInsets()) 179 | .listRowSeparator(.hidden) 180 | } 181 | .environment(\.defaultMinListRowHeight, 1) 182 | .scrollContentBackground(.hidden) 183 | .listStyle(.plain) 184 | .toolbar { 185 | ToolbarItem(placement: .primaryAction) { 186 | Button { 187 | Task { 188 | await loadContent() 189 | } 190 | } label: { 191 | Image(systemName: "arrow.clockwise") 192 | } 193 | .disabled(isLoading) 194 | } 195 | } 196 | .task(id: model.uri) { 197 | feed = [] 198 | cursor = nil 199 | await loadContent() 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /swiftsky/views/FollowersView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FollowersView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | private struct FollowersRowView: View { 9 | @State var user: ActorDefsProfileViewBasic 10 | @State var usernamehover: Bool = false 11 | @State var followingdisabled: Bool = false 12 | @Binding var path: [Navigation] 13 | func follow() { 14 | followingdisabled = true 15 | Task { 16 | do { 17 | let result = try await followUser( 18 | did: user.did) 19 | self.user.viewer?.following = result.uri 20 | } catch { 21 | print(error) 22 | } 23 | followingdisabled = false 24 | } 25 | } 26 | func unfollow() { 27 | followingdisabled = true 28 | Task { 29 | do { 30 | let result = try await repoDeleteRecord( 31 | uri: user.viewer!.following!, collection: "app.bsky.graph.follow") 32 | if result { 33 | self.user.viewer!.following = nil 34 | } 35 | } catch { 36 | print(error) 37 | } 38 | followingdisabled = false 39 | } 40 | } 41 | var body: some View { 42 | HStack(alignment: .top) { 43 | AvatarView(url: user.avatar, size: 40) 44 | let displayname = user.displayName ?? user.handle 45 | VStack(alignment: .leading, spacing: 0) { 46 | Group { 47 | Text(displayname) 48 | .underline(usernamehover) 49 | .hoverHand {usernamehover = $0} 50 | Text("@\(user.handle)").foregroundColor(.secondary) 51 | } 52 | .onTapGesture { 53 | path.append(.profile(user.did)) 54 | } 55 | if user.viewer?.followedBy != nil { 56 | ZStack { 57 | RoundedRectangle(cornerRadius: 10) 58 | .opacity(0.04) 59 | Text("Follows you") 60 | }.padding(.top, 2) 61 | .frame(maxWidth: 90) 62 | } 63 | } 64 | Spacer() 65 | if user.did != Client.shared.did { 66 | let following = user.viewer?.following != nil 67 | Button { 68 | following ? unfollow() : follow() 69 | } label: { 70 | Group { 71 | if !following { 72 | Text("\(Image(systemName: "plus")) Follow") 73 | } 74 | else { 75 | Text("Unfollow") 76 | } 77 | }.frame(maxWidth: 60) 78 | } 79 | .disabled(followingdisabled) 80 | .buttonStyle(.borderedProminent) 81 | .tint(!following ? .accentColor : Color(.controlColor)) 82 | .padding(.trailing, 2) 83 | .frame(maxHeight: .infinity, alignment: .center) 84 | } 85 | } 86 | } 87 | } 88 | struct FollowersView: View { 89 | let handle: String 90 | @State var followers: graphGetFollowersOutput? = nil 91 | @Binding var path: [Navigation] 92 | func getFollowers() async { 93 | do { 94 | self.followers = try await graphGetFollowers(actor: handle) 95 | } catch { 96 | 97 | } 98 | } 99 | func getMoreFollowers(cursor: String) async { 100 | do { 101 | let result = try await graphGetFollowers(actor: handle, cursor: cursor) 102 | self.followers!.cursor = result.cursor 103 | self.followers!.followers.append(contentsOf: result.followers) 104 | } catch { 105 | 106 | } 107 | } 108 | var body: some View { 109 | List { 110 | if let followers { 111 | ForEach(followers.followers) { user in 112 | FollowersRowView(user: user, path: $path) 113 | .padding(5) 114 | .task { 115 | if user == followers.followers.last { 116 | if let cursor = followers.cursor { 117 | await getMoreFollowers(cursor: cursor) 118 | } 119 | } 120 | } 121 | Divider() 122 | } 123 | .listRowInsets(EdgeInsets()) 124 | .listRowSeparator(.hidden) 125 | if followers.cursor != nil { 126 | ProgressView() 127 | .frame(maxWidth: .infinity, alignment: .center) 128 | } 129 | 130 | } 131 | } 132 | .environment(\.defaultMinListRowHeight, 1) 133 | .scrollContentBackground(.hidden) 134 | .listStyle(.plain) 135 | .task { 136 | await getFollowers() 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /swiftsky/views/FollowsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FollowingView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | private struct FollowsRowView: View { 9 | @State var user: ActorDefsProfileViewBasic 10 | @State var usernamehover: Bool = false 11 | @State var followingdisabled: Bool = false 12 | @Binding var path: [Navigation] 13 | func follow() { 14 | followingdisabled = true 15 | Task { 16 | do { 17 | let result = try await followUser( 18 | did: user.did) 19 | self.user.viewer?.following = result.uri 20 | } catch { 21 | print(error) 22 | } 23 | followingdisabled = false 24 | } 25 | } 26 | func unfollow() { 27 | followingdisabled = true 28 | Task { 29 | do { 30 | let result = try await repoDeleteRecord( 31 | uri: user.viewer!.following!, collection: "app.bsky.graph.follow") 32 | if result { 33 | self.user.viewer!.following = nil 34 | } 35 | } catch { 36 | print(error) 37 | } 38 | followingdisabled = false 39 | } 40 | } 41 | var body: some View { 42 | HStack(alignment: .top) { 43 | AvatarView(url: user.avatar, size: 40) 44 | let displayname = user.displayName ?? user.handle 45 | VStack(alignment: .leading, spacing: 0) { 46 | Group { 47 | Text(displayname) 48 | .underline(usernamehover) 49 | .hoverHand {usernamehover = $0} 50 | Text("@\(user.handle)").foregroundColor(.secondary) 51 | } 52 | .onTapGesture { 53 | path.append(.profile(user.did)) 54 | } 55 | if user.viewer?.followedBy != nil { 56 | ZStack { 57 | RoundedRectangle(cornerRadius: 10) 58 | .opacity(0.04) 59 | Text("Follows you") 60 | }.padding(.top, 2) 61 | .frame(maxWidth: 90) 62 | } 63 | } 64 | Spacer() 65 | if user.did != Client.shared.did { 66 | let following = user.viewer?.following != nil 67 | Button { 68 | following ? unfollow() : follow() 69 | } label: { 70 | Group { 71 | if !following { 72 | Text("\(Image(systemName: "plus")) Follow") 73 | } 74 | else { 75 | Text("Unfollow") 76 | } 77 | }.frame(maxWidth: 60) 78 | } 79 | .disabled(followingdisabled) 80 | .buttonStyle(.borderedProminent) 81 | .tint(!following ? .accentColor : Color(.controlColor)) 82 | .padding(.trailing, 2) 83 | .frame(maxHeight: .infinity, alignment: .center) 84 | } 85 | } 86 | } 87 | } 88 | struct FollowsView: View { 89 | let handle: String 90 | @State var follows: graphGetFollowsOutput? = nil 91 | @Binding var path: [Navigation] 92 | func getFollows() async { 93 | do { 94 | self.follows = try await graphGetFollows(actor: handle) 95 | } catch { 96 | 97 | } 98 | } 99 | func getMoreFollows(cursor: String) async { 100 | do { 101 | let result = try await graphGetFollows(actor: handle, cursor: cursor) 102 | self.follows!.cursor = result.cursor 103 | self.follows!.follows.append(contentsOf: result.follows) 104 | } catch { 105 | 106 | } 107 | } 108 | var body: some View { 109 | List { 110 | if let follows { 111 | ForEach(follows.follows) { user in 112 | FollowsRowView(user: user, path: $path) 113 | .padding(5) 114 | .task { 115 | if user == follows.follows.last { 116 | if let cursor = follows.cursor { 117 | await getMoreFollows(cursor: cursor) 118 | } 119 | } 120 | } 121 | Divider() 122 | } 123 | .listRowInsets(EdgeInsets()) 124 | .listRowSeparator(.hidden) 125 | if follows.cursor != nil { 126 | ProgressView() 127 | .frame(maxWidth: .infinity, alignment: .center) 128 | } 129 | 130 | } 131 | } 132 | .environment(\.defaultMinListRowHeight, 1) 133 | .scrollContentBackground(.hidden) 134 | .listStyle(.plain) 135 | .task { 136 | await getFollows() 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /swiftsky/views/GeneralSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneralSettingsView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct GeneralSettingsView: View { 9 | @AppStorage("disablelanguageFilter") private var disablelanguageFilter = false 10 | @AppStorage("hidelikecount") private var hidelikecount = false 11 | @AppStorage("hiderepostcount") private var hiderepostcount = false 12 | var body: some View { 13 | Form { 14 | Toggle("Disable language filter", isOn: $disablelanguageFilter) 15 | Toggle("Hide like count", isOn: $hidelikecount) 16 | Toggle("Hide repost count", isOn: $hiderepostcount) 17 | } 18 | .padding(20) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /swiftsky/views/HomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct HomeView: View { 9 | @State var timeline: FeedGetTimelineOutput = FeedGetTimelineOutput() 10 | @State var loading = false 11 | @Binding var path: [Navigation] 12 | func loadContent() async { 13 | loading = true 14 | do { 15 | self.timeline = try await getTimeline() 16 | } catch { 17 | print(error) 18 | } 19 | loading = false 20 | } 21 | var body: some View { 22 | List { 23 | Group { 24 | let filteredfeed = timeline.feed.filter { 25 | let reply = $0.reply?.parent.author 26 | let following = reply?.viewer?.following 27 | let repost = $0.reason 28 | return ((reply == nil || following != nil || reply?.did == Client.shared.did || $0.post.likeCount >= 5) || repost != nil) 29 | } 30 | if self.loading && !filteredfeed.isEmpty { 31 | ProgressView().frame(maxWidth: .infinity, alignment: .center) 32 | } 33 | ForEach(filteredfeed) { post in 34 | PostView( 35 | post: post.post, reply: post.reply?.parent.author.handle, repost: post.reason, 36 | path: $path 37 | ) 38 | .padding([.top, .horizontal]) 39 | .contentShape(Rectangle()) 40 | .onTapGesture { 41 | path.append(.thread(post.post.uri)) 42 | } 43 | .task { 44 | if post == filteredfeed.last { 45 | if let cursor = self.timeline.cursor { 46 | do { 47 | let result = try await getTimeline(cursor: cursor) 48 | self.timeline.feed.append(contentsOf: result.feed) 49 | self.timeline.cursor = result.cursor 50 | } catch { 51 | print(error) 52 | } 53 | } 54 | } 55 | } 56 | PostFooterView(post: post.post, path: $path) 57 | Divider() 58 | } 59 | if self.timeline.cursor != nil { 60 | ProgressView().frame(maxWidth: .infinity, alignment: .center) 61 | } 62 | } 63 | .listRowInsets(EdgeInsets()) 64 | .listRowSeparator(.hidden) 65 | } 66 | .environment(\.defaultMinListRowHeight, 1) 67 | .scrollContentBackground(.hidden) 68 | .listStyle(.plain) 69 | .toolbar { 70 | ToolbarItem(placement: .primaryAction) { 71 | Button { 72 | Task { 73 | await loadContent() 74 | } 75 | } label: { 76 | Image(systemName: "arrow.clockwise") 77 | } 78 | .disabled(loading) 79 | } 80 | } 81 | .task() { 82 | await loadContent() 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /swiftsky/views/LoginView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | struct LoginView: View { 8 | @State private var handle: String = "" 9 | @State private var password: String = "" 10 | @State private var error: String = "" 11 | @State private var isButtonDisabled: Bool = false 12 | @StateObject private var auth = Auth.shared 13 | private func signin() { 14 | Task { 15 | isButtonDisabled = true 16 | do { 17 | let result = try await ServerCreateSession(identifier: handle, password: password) 18 | Client.shared.user.refreshJwt = result.refreshJwt 19 | Client.shared.user.accessJwt = result.accessJwt 20 | Client.shared.handle = result.handle 21 | Client.shared.did = result.did 22 | Client.shared.user.save() 23 | DispatchQueue.main.async { 24 | auth.needAuthorization = false 25 | } 26 | } catch { 27 | self.error = error.localizedDescription 28 | } 29 | isButtonDisabled = false 30 | } 31 | } 32 | var body: some View { 33 | Form { 34 | Section { 35 | LabeledContent("Handle or email address") { 36 | TextField("Handle", text: $handle) 37 | .textContentType(.username) 38 | .multilineTextAlignment(.trailing) 39 | .labelsHidden() 40 | } 41 | LabeledContent("App password") { 42 | SecureField("Password", text: $password) 43 | .textContentType(.oneTimeCode) 44 | .multilineTextAlignment(.trailing) 45 | .labelsHidden() 46 | } 47 | } header: { 48 | error.isEmpty ? Text("Please sign in to continue.") : Text(error).foregroundColor(.red) 49 | } 50 | } 51 | .formStyle(.grouped) 52 | .navigationTitle("Sign in") 53 | .toolbar { 54 | ToolbarItem(placement: .confirmationAction) { 55 | Button("Sign in") { 56 | if password.count == 19 { 57 | signin() 58 | } 59 | else { 60 | self.error = "Signing in with main password is not allowed" 61 | } 62 | } 63 | .disabled(isButtonDisabled || (handle.isEmpty || password.isEmpty)) 64 | } 65 | ToolbarItem(placement: .cancellationAction) { 66 | Button("Cancel", role: .cancel) { 67 | exit(0) 68 | } 69 | } 70 | } 71 | 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /swiftsky/views/MenuButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuButton.swift 3 | // swiftsky 4 | // 5 | 6 | import Foundation 7 | import SwiftUI 8 | 9 | class MenuItem: NSMenuItem { 10 | init(title: String, action: @escaping () -> Void) { 11 | super.init(title: title, action: nil, keyEquivalent: "") 12 | super.setAction { _ in 13 | action() 14 | } 15 | } 16 | 17 | required init(coder: NSCoder) { 18 | fatalError("init(coder:) has not been implemented") 19 | } 20 | } 21 | 22 | struct MenuButton: NSViewRepresentable { 23 | var items: () -> [MenuItem] 24 | init(_ items: @escaping () -> [MenuItem]) { 25 | self.items = items 26 | } 27 | func makeNSView(context: NSViewRepresentableContext) -> NSButton { 28 | let button = NSButton() 29 | button.title = "" 30 | button.image = NSImage(systemSymbolName: "ellipsis", accessibilityDescription: nil) 31 | button.bezelStyle = .texturedRounded 32 | button.isBordered = false 33 | let menu = NSMenu() 34 | let items = self.items() 35 | for item in items { 36 | menu.addItem(item) 37 | } 38 | button.setAction { _ in 39 | menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.frame.height + 5), in: button) 40 | } 41 | return button 42 | } 43 | func updateNSView(_ nsView: NSButton, context: NSViewRepresentableContext) { 44 | 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /swiftsky/views/NewPostView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewPostView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct ImageAttachment: Identifiable { 9 | let id = UUID() 10 | let image: Image 11 | let data: Data 12 | } 13 | struct NewPostView: View { 14 | @Environment(\.dismiss) private var dismiss 15 | @State private var text = "" 16 | @State private var disablebuttons: Bool = false 17 | @State private var isFilePickerPresented: Bool = false 18 | @State private var error: String? 19 | @State private var images: [ImageAttachment] = [] 20 | @StateObject private var globalmodel = GlobalViewModel.shared 21 | var post: FeedDefsPostView? = nil 22 | var isquote: Bool = false 23 | func makepost() { 24 | disablebuttons = true 25 | Task { 26 | do { 27 | var images: [EmbedImagesImage] = [] 28 | for image in self.images { 29 | let result = try await repouploadBlob(data: image.data) 30 | images.append(.init(alt: "", image: result.blob)) 31 | } 32 | let embed = EmbedRef(record: isquote ? .init(cid: post!.cid, uri: post!.uri) : nil, images: !images.isEmpty ? .init(images: images) : nil) 33 | var replyref: FeedPostReplyRef? = nil 34 | if !isquote, let post { 35 | let parent = RepoStrongRef(cid: post.cid, uri: post.uri) 36 | let root = post.record.reply != nil ? RepoStrongRef(cid: post.record.reply!.root.cid, uri: post.record.reply!.root.uri) : parent 37 | replyref = FeedPostReplyRef(parent: parent, root: root) 38 | } 39 | let rt = RichText(text: text, facets: nil) 40 | let facets = await rt.detectFacets() 41 | let _ = try await makePost(text: text, reply: replyref, facets: facets, embed: embed.isValid() ? embed : nil) 42 | dismiss() 43 | } catch { 44 | self.error = error.localizedDescription 45 | } 46 | disablebuttons = false 47 | } 48 | } 49 | var body: some View { 50 | VStack { 51 | HStack { 52 | Button("Cancel") { 53 | dismiss() 54 | } 55 | .buttonStyle(.plain) 56 | .padding([.leading, .top], 20) 57 | .foregroundColor(.accentColor) 58 | .disabled(disablebuttons) 59 | Spacer() 60 | Button("Post") { 61 | makepost() 62 | } 63 | .buttonStyle(.borderedProminent) 64 | .tint(.accentColor) 65 | .disabled(text.count > 300 || disablebuttons || (text.isEmpty && images.isEmpty)) 66 | .padding([.trailing, .top], 20) 67 | } 68 | if let post { 69 | Divider().padding(.vertical, 5) 70 | HStack(alignment: .top, spacing: 12) { 71 | AvatarView(url: post.author.avatar, size: 40) 72 | VStack(alignment: .leading, spacing: 2) { 73 | Text(post.author.displayName ?? post.author.handle) 74 | .fontWeight(.semibold) 75 | Text(post.record.text) 76 | } 77 | Spacer() 78 | } 79 | .padding(.leading, 20) 80 | } 81 | Divider() 82 | .padding(.vertical, 5) 83 | HStack(alignment: .top) { 84 | AvatarView(url: globalmodel.profile?.avatar, size: 50) 85 | let placeholder = post != nil && !isquote ? "Reply to @\(post!.author.handle)" : "What's up?" 86 | VStack(alignment: .leading) { 87 | TextViewWrapper(text: $text, placeholder: placeholder) { 88 | if images.count >= 4 { 89 | return 90 | } 91 | let imgData = NSPasteboard.general.data(forType: .png) 92 | if let imgData { 93 | DispatchQueue.main.async { 94 | if let image = NSImage(data: imgData) { 95 | images.append(.init(image: Image(nsImage: image), data: imgData)) 96 | } 97 | } 98 | } 99 | } 100 | .frame(height: 200) 101 | ScrollView(.horizontal) { 102 | HStack { 103 | ForEach(Array(images.enumerated()), id: \.element.id) { index, image in 104 | image.image 105 | .resizable() 106 | .scaledToFill() 107 | .frame(width: 150, height: 150) 108 | .clipped() 109 | .overlay(alignment: .topTrailing) { 110 | Button { 111 | images.remove(at: index) 112 | } label : { 113 | Image(systemName: "xmark.circle.fill") 114 | .foregroundStyle( 115 | .secondary, 116 | .clear, 117 | .black 118 | ) 119 | .padding(5) 120 | } 121 | .buttonStyle(.borderless) 122 | .font(.title) 123 | } 124 | } 125 | } 126 | } 127 | } 128 | } 129 | .padding([.leading], 20) 130 | 131 | Divider() 132 | .padding(.vertical, 5) 133 | HStack { 134 | Button("\(Image(systemName: "photo"))") { 135 | isFilePickerPresented.toggle() 136 | } 137 | .buttonStyle(.plain) 138 | .foregroundColor(.accentColor) 139 | .disabled(images.count >= 4) 140 | .font(.title) 141 | .padding(.leading) 142 | Spacer() 143 | let replycount = 300 - text.count 144 | Text("\(replycount)") 145 | .padding(.trailing, 20) 146 | .foregroundColor(replycount < 0 ? .red : .primary) 147 | } 148 | Spacer() 149 | } 150 | .fileImporter(isPresented: $isFilePickerPresented, allowedContentTypes: [.image], allowsMultipleSelection: true) { result in 151 | if let urls = try? result.get() { 152 | for url in urls { 153 | if images.count >= 4 { 154 | break 155 | } 156 | guard url.startAccessingSecurityScopedResource() else {continue} 157 | if let data = try? Data(contentsOf: url) { 158 | if let image = NSImage(data: data) { 159 | images.append(.init(image: Image(nsImage: image), data: data)) 160 | } 161 | } 162 | url.stopAccessingSecurityScopedResource() 163 | } 164 | } 165 | } 166 | 167 | .alert("Error: \(self.error ?? "Unknown")", isPresented: .constant(error != nil)) { 168 | Button("OK") { 169 | error = nil 170 | } 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /swiftsky/views/NotificationsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationsView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | private struct NotificationsViewFollow: View { 9 | @State var notification: NotificationListNotificationsNotification 10 | @State var underline = false 11 | @Binding var path: [Navigation] 12 | var body: some View { 13 | Group { 14 | if let author = notification.author { 15 | HStack { 16 | Image(systemName: "person.crop.circle.badge.plus") 17 | .resizable() 18 | .aspectRatio(contentMode: .fill) 19 | .frame(width: 25, height: 25) 20 | .padding(5) 21 | .clipped() 22 | .foregroundColor(.accentColor) 23 | AvatarView(url: author.avatar, size: 40) 24 | HStack(spacing: 3) { 25 | Button { 26 | path.append(.profile(author.did)) 27 | } label: { 28 | Text("@\(author.handle)") 29 | .foregroundColor(.primary) 30 | .underline(underline) 31 | .hoverHand { 32 | underline = $0 33 | } 34 | .tooltip { 35 | ProfilePreview(did: author.did, path: $path) 36 | } 37 | 38 | }.buttonStyle(.plain) 39 | Text("followed you") 40 | .opacity(0.8) 41 | 42 | Text( 43 | Formatter.relativeDateNamed.localizedString( 44 | fromTimeInterval: notification.indexedAt.timeIntervalSinceNow) 45 | ) 46 | .font(.body) 47 | .foregroundColor(.secondary) 48 | .help(notification.indexedAt.formatted(date: .complete, time: .standard)) 49 | } 50 | } 51 | } 52 | } 53 | } 54 | } 55 | 56 | private struct NotificationsViewRepost: View { 57 | @State var notification: NotificationListNotificationsNotification 58 | @State var underline = false 59 | @Binding var path: [Navigation] 60 | var body: some View { 61 | Group { 62 | if let author = notification.author { 63 | HStack { 64 | Image(systemName: "arrow.triangle.2.circlepath") 65 | .resizable() 66 | .aspectRatio(contentMode: .fill) 67 | .frame(width: 25, height: 25) 68 | .padding(5) 69 | .clipped() 70 | .foregroundColor(.accentColor) 71 | AvatarView(url: author.avatar, size: 40) 72 | HStack(spacing: 3) { 73 | Button { 74 | path.append(.profile(author.did)) 75 | } label: { 76 | Text("@\(author.handle)") 77 | .foregroundColor(.primary) 78 | .underline(underline) 79 | .hoverHand { 80 | underline = $0 81 | } 82 | .tooltip { 83 | ProfilePreview(did: author.did, path: $path) 84 | } 85 | 86 | }.buttonStyle(.plain) 87 | Text("reposted your post") 88 | .opacity(0.8) 89 | 90 | Text( 91 | Formatter.relativeDateNamed.localizedString( 92 | fromTimeInterval: notification.indexedAt.timeIntervalSinceNow) 93 | ) 94 | .font(.body) 95 | .foregroundColor(.secondary) 96 | .help(notification.indexedAt.formatted(date: .complete, time: .standard)) 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | private struct NotificationsViewLike: View { 104 | @State var notification: NotificationListNotificationsNotification 105 | @State var underline = false 106 | @Binding var path: [Navigation] 107 | var body: some View { 108 | Group { 109 | if let author = notification.author { 110 | HStack { 111 | Image(systemName: "heart.fill") 112 | .resizable() 113 | .aspectRatio(contentMode: .fill) 114 | .frame(width: 25, height: 25) 115 | .padding(5) 116 | .clipped() 117 | .foregroundColor(.pink) 118 | AvatarView(url: author.avatar, size: 40) 119 | HStack(spacing: 3) { 120 | Button { 121 | path.append(.profile(author.did)) 122 | } label: { 123 | Text("@\(author.handle)") 124 | .foregroundColor(.primary) 125 | .underline(underline) 126 | .hoverHand { 127 | underline = $0 128 | } 129 | .tooltip { 130 | ProfilePreview(did: author.did, path: $path) 131 | } 132 | }.buttonStyle(.plain) 133 | 134 | Text("liked your post") 135 | .opacity(0.8) 136 | 137 | Text( 138 | Formatter.relativeDateNamed.localizedString( 139 | fromTimeInterval: notification.indexedAt.timeIntervalSinceNow) 140 | ) 141 | .font(.body) 142 | .foregroundColor(.secondary) 143 | .help(notification.indexedAt.formatted(date: .complete, time: .standard)) 144 | } 145 | } 146 | } 147 | } 148 | } 149 | } 150 | struct NotificationsView: View { 151 | @State var notifications: NotificationListNotificationsOutput? 152 | @Binding var path: [Navigation] 153 | func getNotifications(cursor: String? = nil) async { 154 | do { 155 | let notifications = try await NotificationListNotifications(cursor: cursor) 156 | if cursor != nil { 157 | self.notifications?.notifications.append(contentsOf: notifications.notifications) 158 | self.notifications?.cursor = notifications.cursor 159 | } 160 | else { 161 | self.notifications = notifications 162 | } 163 | let uris = notifications.notifications.compactMap { 164 | if $0.uri.contains("app.bsky.feed.post") { 165 | return $0.uri 166 | } 167 | return nil 168 | } 169 | let posts = try await feedgetPosts(uris: uris) 170 | for post in posts.posts { 171 | if let notif = self.notifications?.notifications.firstIndex(where: { 172 | $0.uri == post.uri 173 | }) { 174 | self.notifications?.notifications[notif].post = post 175 | 176 | } 177 | } 178 | 179 | } catch { 180 | } 181 | } 182 | var body: some View { 183 | List { 184 | Group { 185 | if let notifications { 186 | ForEach(notifications.notifications) { notification in 187 | Group { 188 | switch notification.reason { 189 | case "follow": 190 | NotificationsViewFollow(notification: notification, path: $path) 191 | .padding(.bottom, 5) 192 | .frame(maxWidth: .infinity, alignment: .topLeading) 193 | case "repost": 194 | NotificationsViewRepost(notification: notification, path: $path) 195 | .padding(.bottom, 5) 196 | .frame(maxWidth: .infinity, alignment: .topLeading) 197 | case "like": 198 | NotificationsViewLike(notification: notification, path: $path) 199 | .padding(.bottom, 5) 200 | .frame(maxWidth: .infinity, alignment: .topLeading) 201 | default: 202 | EmptyView() 203 | } 204 | 205 | if let post = notification.post { 206 | PostView(post: post, path: $path) 207 | .padding(.horizontal) 208 | .padding(.top, notification.cid == notifications.notifications.first?.cid ? 5 : 0) 209 | PostFooterView(post: post, path: $path) 210 | .frame(maxWidth: .infinity, alignment: .topLeading) 211 | } 212 | } 213 | .background { 214 | if !notification.isRead { 215 | Color.blue 216 | .opacity(0.5) 217 | } 218 | } 219 | .onAppear { 220 | Task { 221 | if let cursor = notifications.cursor, notification.cid == notifications.notifications.last?.cid { 222 | await getNotifications(cursor: cursor) 223 | } 224 | } 225 | 226 | } 227 | Divider() 228 | .padding(.bottom, 5) 229 | } 230 | 231 | } 232 | } 233 | .listRowInsets(.init()) 234 | .listRowSeparator(.hidden) 235 | } 236 | .listStyle(.plain) 237 | .environment(\.defaultMinListRowHeight, 1) 238 | .scrollContentBackground(.hidden) 239 | .task { 240 | await getNotifications() 241 | PushNotificatios.shared.markasRead() 242 | } 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /swiftsky/views/PostFooterView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostFooterView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct PostLikesSubview: View { 9 | let avatar: String? 10 | let displayname: String 11 | let handle: String 12 | @State var usernamehover = false 13 | var body: some View { 14 | HStack(alignment: .top) { 15 | AvatarView(url: avatar, size: 40) 16 | VStack(alignment: .leading) { 17 | Text(displayname) 18 | .foregroundColor(.primary) 19 | .lineLimit(1) 20 | .underline(usernamehover) 21 | .hoverHand { 22 | usernamehover = $0 23 | } 24 | Text("@\(handle)") 25 | .lineLimit(1) 26 | .foregroundColor(.secondary) 27 | } 28 | } 29 | } 30 | } 31 | struct PostLikesView: View { 32 | @State var post: FeedDefsPostView 33 | @State var likes = feedgetLikesOutput() 34 | @State var loading = true 35 | @State var error = "" 36 | @State var listheight = 40.0 37 | @Binding var path: [Navigation] 38 | private func getLikes() { 39 | Task { 40 | self.loading = true 41 | do { 42 | let likes = try await feedgetLikes(cid: post.cid, cursor: likes.cursor, uri: post.uri) 43 | self.likes.likes.append(contentsOf: likes.likes) 44 | self.likes.cursor = likes.cursor 45 | if !self.likes.likes.isEmpty { 46 | self.listheight = min(Double(self.likes.likes.count) * 42.0, 250) 47 | } 48 | } catch { 49 | if self.likes.likes.isEmpty { 50 | listheight = 80 51 | } 52 | self.error = error.localizedDescription 53 | } 54 | self.loading = false 55 | } 56 | 57 | } 58 | 59 | var body: some View { 60 | ScrollView { 61 | LazyVStack { 62 | if likes.likes.isEmpty && error.isEmpty && loading == false { 63 | VStack { 64 | Spacer() 65 | Text("This post doesnt have any likes yet") 66 | .frame(maxWidth: .infinity, alignment: .center) 67 | Spacer() 68 | } 69 | 70 | } 71 | ForEach(likes.likes) { user in 72 | Button { 73 | path.append(.profile(user.actor.did)) 74 | } label: { 75 | PostLikesSubview(avatar: user.actor.avatar, displayname: user.actor.displayName ?? user.actor.handle, handle: user.actor.handle) 76 | .frame(maxWidth: .infinity, alignment: .leading) 77 | .contentShape(Rectangle()) 78 | } 79 | .buttonStyle(.plain) 80 | .frame(height: 35) 81 | .task { 82 | if user.id == likes.likes.last?.id && likes.cursor != nil { 83 | getLikes() 84 | } 85 | } 86 | } 87 | 88 | if loading { 89 | ProgressView().frame(maxWidth: .infinity, alignment: .center) 90 | } 91 | if !self.error.isEmpty { 92 | Group { 93 | Text("Error: \(error)") 94 | Button("\(Image(systemName: "arrow.clockwise")) Retry") { 95 | error = "" 96 | getLikes() 97 | } 98 | .buttonStyle(.borderedProminent) 99 | .controlSize(.large) 100 | } 101 | .frame(maxWidth: .infinity, alignment: .center) 102 | } 103 | } 104 | .padding(.top, 6) 105 | .padding(.leading, 6) 106 | }.task { 107 | getLikes() 108 | } 109 | .frame(width: 250, height: listheight) 110 | } 111 | } 112 | 113 | struct PostFooterView: View { 114 | var bottompadding = true 115 | var leadingpadding = 68.0 116 | @State var post: FeedDefsPostView 117 | @State private var likedisabled: Bool = false 118 | @State private var repostdisabled: Bool = false 119 | @State private var likesunderline: Bool = false 120 | @State private var likespopover: Bool = false 121 | @State private var isrepostPresented: Bool = false 122 | @State private var isquotepostPresented: Bool = false 123 | @State private var isreplypostPresented: Bool = false 124 | @Binding var path: [Navigation] 125 | @AppStorage("hidelikecount") private var hidelikecount = false 126 | @AppStorage("hiderepostcount") private var hiderepostcount = false 127 | func like() { 128 | post.viewer.like = "" 129 | post.likeCount += 1 130 | Task { 131 | do { 132 | let result = try await likePost(uri: post.uri, cid: post.cid) 133 | post.viewer.like = result.uri 134 | } catch { 135 | post.viewer.like = nil 136 | post.likeCount -= 1 137 | } 138 | likedisabled = false 139 | } 140 | } 141 | func unlike() { 142 | let like = post.viewer.like 143 | post.viewer.like = nil 144 | post.likeCount -= 1 145 | Task { 146 | do { 147 | if try await repoDeleteRecord(uri: like!, collection: "app.bsky.feed.like") { 148 | post.viewer.like = nil 149 | } 150 | } catch { 151 | post.viewer.like = like 152 | post.likeCount += 1 153 | } 154 | likedisabled = false 155 | } 156 | } 157 | func repost() { 158 | post.viewer.repost = "" 159 | post.repostCount += 1 160 | Task { 161 | do { 162 | let result = try await RepostPost(uri: post.uri, cid: post.cid) 163 | post.viewer.repost = result.uri 164 | } catch { 165 | post.viewer.repost = nil 166 | post.repostCount -= 1 167 | } 168 | repostdisabled = false 169 | } 170 | } 171 | func undorepost() { 172 | let repost = post.viewer.repost 173 | post.viewer.repost = nil 174 | post.repostCount -= 1 175 | Task { 176 | do { 177 | if try await repoDeleteRecord(uri: repost!, collection: "app.bsky.feed.repost") { 178 | post.viewer.repost = nil 179 | } 180 | } catch { 181 | post.viewer.repost = repost 182 | post.repostCount += 1 183 | } 184 | repostdisabled = false 185 | } 186 | } 187 | var body: some View { 188 | HStack(alignment: .top, spacing: 0) { 189 | Button { 190 | isreplypostPresented.toggle() 191 | } label: { 192 | Text("\(Image(systemName: "bubble.right")) \(post.replyCount)") 193 | } 194 | .buttonStyle(.plain) 195 | .frame(width: 70, alignment: .leading) 196 | Button { 197 | isrepostPresented.toggle() 198 | } label: { 199 | Text("\(Image(systemName: "arrow.triangle.2.circlepath")) \(hiderepostcount ? "Hidden" : "\(post.repostCount)")") 200 | .foregroundColor(post.viewer.repost != nil ? .cyan : .secondary) 201 | .popover(isPresented: $isrepostPresented, arrowEdge: .bottom) { 202 | VStack(alignment: .leading) { 203 | Button { 204 | isrepostPresented = false 205 | repostdisabled = true 206 | post.viewer.repost == nil ? repost() : undorepost() 207 | } label : { 208 | Image(systemName: "arrowshape.turn.up.backward.fill") 209 | Text(post.viewer.repost == nil ? "Repost" : "Undo repost") 210 | .font(.system(size: 15)) 211 | .frame(maxWidth: .infinity, alignment: .topLeading) 212 | .contentShape(Rectangle()) 213 | } 214 | .buttonStyle(.plain) 215 | .padding([.top, .leading], 10) 216 | .padding(.bottom, 2) 217 | .disabled(repostdisabled) 218 | Button { 219 | isquotepostPresented.toggle() 220 | } label : { 221 | Image(systemName: "quote.opening") 222 | Text("Quote Post") 223 | .font(.system(size: 15)) 224 | .frame(maxWidth: .infinity, alignment: .topLeading) 225 | .contentShape(Rectangle()) 226 | } 227 | .buttonStyle(.plain) 228 | .padding(.leading, 10) 229 | } 230 | .frame(width: 150, height: 70, alignment: .topLeading) 231 | 232 | } 233 | } 234 | .buttonStyle(.plain) 235 | .frame(width: 70, alignment: .leading) 236 | Group { 237 | Button { 238 | if !likedisabled { 239 | likedisabled = true 240 | if post.viewer.like == nil { 241 | like() 242 | } else { 243 | unlike() 244 | } 245 | } 246 | } label: { 247 | Text("\(Image(systemName: "heart")) ") 248 | } 249 | .disabled(likedisabled) 250 | .buttonStyle(.plain) 251 | .frame(alignment: .leading) 252 | Text(hidelikecount ? "Hidden" : "\(post.likeCount)") 253 | .underline(likesunderline) 254 | .hoverHand { 255 | likesunderline = $0 256 | } 257 | .onTapGesture { 258 | likespopover.toggle() 259 | } 260 | .popover(isPresented: $likespopover, arrowEdge: .bottom) { 261 | PostLikesView(post: post, path: $path) 262 | } 263 | .frame(alignment: .leading) 264 | } 265 | .foregroundColor(post.viewer.like != nil ? .pink : .secondary) 266 | } 267 | .padding(.bottom, bottompadding ? 10 : 0) 268 | .padding(.leading, leadingpadding) 269 | .foregroundColor(.secondary) 270 | .sheet(isPresented: $isquotepostPresented) { 271 | NewPostView(post: post, isquote: true) 272 | .frame(width: 600) 273 | .fixedSize() 274 | } 275 | .sheet(isPresented: $isreplypostPresented) { 276 | NewPostView(post: post) 277 | .frame(width: 600) 278 | .fixedSize() 279 | } 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /swiftsky/views/PostView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostView.swift 3 | // swiftsky 4 | // 5 | 6 | import QuickLook 7 | import SwiftUI 8 | 9 | struct PostView: View { 10 | @State var post: FeedDefsPostView 11 | @State var reply: String? 12 | @State var usernamehover: Bool = false 13 | @State var repost: FeedDefsFeedViewPostReason? = nil 14 | @State var deletepostfailed = false 15 | @State var deletepost = false 16 | @State var underlinereply = false 17 | @State var translateavailable = false 18 | @StateObject var translateviewmodel = TranslateViewModel() 19 | @Binding var path: [Navigation] 20 | func delete() { 21 | Task { 22 | do { 23 | let result = try await repoDeleteRecord(uri: post.uri, collection: "app.bsky.feed.post") 24 | if result { 25 | } 26 | } catch { 27 | deletepostfailed = true 28 | } 29 | } 30 | } 31 | var markdown: String { 32 | var markdown = String() 33 | let rt = RichText(text: post.record.text, facets: post.record.facets) 34 | for segment in rt.segments() { 35 | if let link = segment.link() { 36 | markdown += "[\(segment.text)](\(link))" 37 | } 38 | else if let mention = segment.mention() { 39 | markdown += "[\(segment.text)](swiftsky://profile?did=\(mention))" 40 | } 41 | else { 42 | markdown += segment.text 43 | } 44 | } 45 | return markdown 46 | } 47 | var body: some View { 48 | HStack(alignment: .top, spacing: 12) { 49 | AvatarView(url: post.author.avatar, size: 40) 50 | VStack(alignment: .leading, spacing: 2) { 51 | if let repost = repost { 52 | Text( 53 | "\(Image(systemName: "arrow.triangle.2.circlepath")) Reposted by \(repost.by.handle)" 54 | ) 55 | .foregroundColor(.secondary) 56 | } 57 | HStack(alignment: .firstTextBaseline) { 58 | let displayname = post.author.displayName ?? post.author.handle 59 | 60 | Button { 61 | path.append(.profile(post.author.did)) 62 | } label: { 63 | Text("\(displayname) \(Text(post.author.handle).foregroundColor(.secondary))") 64 | .fontWeight(.semibold) 65 | .underline(usernamehover) 66 | } 67 | .buttonStyle(.plain) 68 | .hoverHand {usernamehover = $0} 69 | .tooltip { 70 | ProfilePreview(did: post.author.did, path: $path) 71 | } 72 | Text( 73 | Formatter.relativeDateNamed.localizedString( 74 | fromTimeInterval: post.indexedAt.timeIntervalSinceNow) 75 | ) 76 | .font(.body) 77 | .foregroundColor(.secondary) 78 | .help(post.indexedAt.formatted(date: .complete, time: .standard)) 79 | 80 | Spacer() 81 | Group { 82 | MenuButton { 83 | var items: [MenuItem] = [] 84 | items.append( 85 | MenuItem(title: "Share") { 86 | print("Share") 87 | }) 88 | items.append( 89 | MenuItem(title: "Report") { 90 | print("Report") 91 | }) 92 | if post.author.did == Client.shared.did { 93 | items.append( 94 | MenuItem(title: "Delete") { 95 | deletepost = true 96 | }) 97 | } 98 | return items 99 | } 100 | .frame(width: 30, height: 30) 101 | .contentShape(Rectangle()) 102 | .hoverHand() 103 | } 104 | .frame(height: 0) 105 | } 106 | if let reply = reply { 107 | HStack(spacing: 0) { 108 | Text("Reply to ").foregroundColor(.secondary) 109 | Button { 110 | path.append(.profile(reply)) 111 | } label: { 112 | Text("@\(reply)").foregroundColor(Color(.linkColor)) 113 | .underline(underlinereply) 114 | .hoverHand { 115 | underlinereply = $0 116 | } 117 | .tooltip { 118 | ProfilePreview(did: reply, path: $path) 119 | } 120 | } 121 | .buttonStyle(.plain) 122 | } 123 | } 124 | if !post.record.text.isEmpty { 125 | Text(.init(markdown)) 126 | .textSelection(.enabled) 127 | .padding(.bottom, post.embed?.images == nil ? self.translateavailable ? 0 : 6 : 0) 128 | if self.translateavailable { 129 | TranslateView(viewmodel: translateviewmodel) 130 | } 131 | } 132 | if let embed = post.embed { 133 | if let images = embed.images { 134 | HStack { 135 | ForEach(images) { image in 136 | Button { 137 | GlobalViewModel.shared.preview = URL(string: image.fullsize) 138 | } label: { 139 | let imagewidth = 500.0 / Double(images.count) 140 | let imageheight = 500.0 / Double(images.count) 141 | AsyncImage(url: URL(string: image.thumb)) { image in 142 | image 143 | .resizable() 144 | .aspectRatio(contentMode: .fit) 145 | .frame(maxWidth: imagewidth, maxHeight: imageheight, alignment: .topLeading) 146 | .clipped() 147 | } placeholder: { 148 | ProgressView() 149 | .frame(width: imagewidth, height: imageheight) 150 | } 151 | .padding(.init(top: 5, leading: 0, bottom: 5, trailing: 0)) 152 | .cornerRadius(15) 153 | } 154 | .buttonStyle(.plain) 155 | } 156 | } 157 | } 158 | if let record: EmbedRecordViewRecord = embed.record { 159 | EmbedPostView(embedrecord: record, path: $path) 160 | .onTapGesture { 161 | path.append(.thread(record.uri)) 162 | } 163 | } 164 | if let external = embed.external { 165 | EmbedExternalView(record: external) 166 | } 167 | } 168 | } 169 | } 170 | .onAppear { 171 | if translateviewmodel.text.isEmpty && !post.record.text.isEmpty { 172 | if post.record.text.languageCode != GlobalViewModel.shared.systemLanguageCode { 173 | translateavailable = true 174 | } 175 | translateviewmodel.text = post.record.text 176 | } 177 | } 178 | .alert("Failed to delete post, please try again.", isPresented: $deletepostfailed, actions: {}) 179 | .alert("Are you sure?", isPresented: $deletepost) { 180 | Button("Cancel", role: .cancel) {} 181 | Button("Delete", role: .destructive) { 182 | self.delete() 183 | } 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /swiftsky/views/ProfilePreview.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfilePreview.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct ProfilePreview: View { 9 | let did: String 10 | @State private var profile: ActorDefsProfileViewDetailed? = nil 11 | @State private var height = 50.0 12 | @State private var loading = false 13 | @State private var usernamehover = false 14 | @State private var handlehover = false 15 | @State private var disablefollowbutton = false 16 | @State private var error = "" 17 | @Binding var path: [Navigation] 18 | func load() async { 19 | loading = true 20 | do { 21 | self.profile = try await actorgetProfile(actor: did) 22 | } 23 | catch { 24 | self.error = error.localizedDescription 25 | } 26 | loading = false 27 | } 28 | private func follow() { 29 | disablefollowbutton = true 30 | Task { 31 | if let result = try? await followUser( 32 | did: did) { 33 | profile!.viewer?.following = result.uri 34 | profile!.followersCount += 1 35 | } 36 | disablefollowbutton = false 37 | } 38 | } 39 | private func unfollow() { 40 | disablefollowbutton = true 41 | Task { 42 | let result = try? await repoDeleteRecord( 43 | uri: profile!.viewer!.following!, collection: "app.bsky.graph.follow") 44 | if result == true { 45 | self.profile!.viewer!.following = nil 46 | profile!.followersCount -= 1 47 | } 48 | disablefollowbutton = false 49 | } 50 | } 51 | var body: some View { 52 | Group { 53 | if loading { 54 | ProgressView() 55 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) 56 | } 57 | if !self.error.isEmpty { 58 | VStack { 59 | Text("Error: \(error)") 60 | Button("\(Image(systemName: "arrow.clockwise")) Retry") { 61 | error = "" 62 | Task { 63 | await load() 64 | } 65 | } 66 | .buttonStyle(.borderedProminent) 67 | .controlSize(.large) 68 | } 69 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) 70 | } 71 | if let profile { 72 | VStack(alignment: .leading) { 73 | HStack(alignment: .top) { 74 | AvatarView(url: profile.avatar, size: 40) 75 | Spacer() 76 | if profile.did != Client.shared.did { 77 | Button(profile.viewer?.following != nil ? "Following" : "Follow") { 78 | profile.viewer?.following != nil ? unfollow() : follow() 79 | } 80 | .disabled(disablefollowbutton) 81 | } 82 | 83 | } 84 | Button { 85 | path.append(.profile(did)) 86 | } label: { 87 | Text(profile.displayName ?? profile.handle) 88 | .lineLimit(1) 89 | .underline(usernamehover) 90 | .hoverHand { 91 | usernamehover = $0 92 | } 93 | } 94 | .buttonStyle(.plain) 95 | Button { 96 | path.append(.profile(did)) 97 | } label: { 98 | Text("@\(profile.handle)").opacity(0.5) 99 | .lineLimit(1) 100 | .underline(handlehover) 101 | .hoverHand { 102 | handlehover = $0 103 | } 104 | } 105 | .buttonStyle(.plain) 106 | .lineLimit(1) 107 | if let description = profile.description { 108 | Text(description) 109 | .fixedSize(horizontal: false, vertical: true) 110 | .lineLimit(3) 111 | } 112 | HStack { 113 | Button { 114 | path.append(.followers(profile.handle)) 115 | } label: { 116 | HStack(spacing: 0) { 117 | Text("\(profile.followersCount) ") 118 | Text("followers").opacity(0.5) 119 | } 120 | } 121 | .buttonStyle(.plain) 122 | Button { 123 | path.append(.following(profile.handle)) 124 | } label: { 125 | HStack(spacing: 0) { 126 | Text("\(profile.followsCount) ") 127 | Text("following").opacity(0.5) 128 | } 129 | } 130 | .buttonStyle(.plain) 131 | } 132 | } 133 | .padding(10) 134 | .background { 135 | GeometryReader { proxy in 136 | Color.clear 137 | .onAppear { 138 | height = proxy.size.height 139 | } 140 | } 141 | } 142 | } 143 | } 144 | .frame(width: 230, height: height, alignment: .topLeading) 145 | .task { 146 | await load() 147 | } 148 | 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /swiftsky/views/ProfileView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileView.swift 3 | // swiftsky 4 | // 5 | 6 | import QuickLook 7 | import SwiftUI 8 | 9 | private struct ProfileViewTabs: View { 10 | @Namespace var namespace 11 | @Binding var selectedtab: Int 12 | let tabs: [String] 13 | var body: some View { 14 | ForEach(tabs.indices, id: \.self) { i in 15 | Button { 16 | selectedtab = i 17 | } label: { 18 | Text(tabs[i]) 19 | .padding(.bottom, 2) 20 | .overlay(alignment: .bottomLeading) { 21 | if selectedtab == i { 22 | Color.primary 23 | .frame(height: 2) 24 | .matchedGeometryEffect(id: "underline", 25 | in: namespace) 26 | } 27 | } 28 | .animation(.spring(), value: selectedtab) 29 | } 30 | .buttonStyle(.plain) 31 | .hoverHand() 32 | } 33 | } 34 | } 35 | struct ProfileView: View { 36 | let did: String 37 | @State var profile: ActorDefsProfileViewDetailed? 38 | @State private var authorfeed = FeedGetAuthorFeedOutput() 39 | @State private var likedposts = FeedGetAuthorFeedOutput() 40 | @State private var selectedtab = 0 41 | @State private var loading = false 42 | @State private var error = "" 43 | @State private var disablefollowbutton = false 44 | @State private var disableblockbutton = false 45 | @State private var isblockalertPresented = false 46 | @Binding var path: [Navigation] 47 | let tablist: [String] = ["Posts", "Posts & Replies", "Likes"] 48 | private func getProfile() async { 49 | do { 50 | self.profile = try await actorgetProfile(actor: did) 51 | } catch { 52 | } 53 | } 54 | private func getFeed(cursor: String? = nil) async { 55 | do { 56 | let authorfeed = try await getAuthorFeed(actor: did, cursor: cursor) 57 | if cursor == nil { 58 | self.authorfeed = authorfeed 59 | return 60 | } 61 | self.authorfeed.cursor = authorfeed.cursor 62 | if !authorfeed.feed.isEmpty { 63 | self.authorfeed.feed.append(contentsOf: authorfeed.feed) 64 | } 65 | } catch { 66 | self.error = error.localizedDescription 67 | } 68 | } 69 | private func getLikes(cursor: String? = nil) async { 70 | do { 71 | let records = try await RepoListRecords(collection: "app.bsky.feed.like", cursor: cursor, limit: 25, repo: self.did) 72 | self.likedposts.cursor = records.cursor 73 | let uris = records.records.map { 74 | $0.value.subject.uri 75 | } 76 | if uris.isEmpty { 77 | return 78 | } 79 | let posts = try await feedgetPosts(uris: uris) 80 | .posts.map { 81 | FeedDefsFeedViewPost(post: $0) 82 | } 83 | if cursor != nil { 84 | self.likedposts.feed.append(contentsOf: posts) 85 | } 86 | else { 87 | self.likedposts.feed = posts 88 | } 89 | 90 | } catch { 91 | self.error = error.localizedDescription 92 | } 93 | } 94 | private func unfollow() { 95 | disablefollowbutton = true 96 | Task { 97 | do { 98 | let result = try await repoDeleteRecord( 99 | uri: profile!.viewer!.following!, collection: "app.bsky.graph.follow") 100 | if result { 101 | self.profile!.viewer!.following = nil 102 | } 103 | } catch { 104 | self.error = error.localizedDescription 105 | } 106 | disablefollowbutton = false 107 | } 108 | } 109 | private func unblock() { 110 | disableblockbutton = true 111 | Task { 112 | do { 113 | let result = try await repoDeleteRecord( 114 | uri: profile!.viewer!.blocking!, collection: "app.bsky.graph.block") 115 | if result { 116 | await load() 117 | } 118 | } catch { 119 | self.error = error.localizedDescription 120 | } 121 | disableblockbutton = false 122 | } 123 | } 124 | private func block() { 125 | disableblockbutton = true 126 | Task { 127 | do { 128 | let _ = try await blockUser( 129 | did: did) 130 | await load() 131 | } catch { 132 | self.error = error.localizedDescription 133 | } 134 | disableblockbutton = false 135 | } 136 | } 137 | private func follow() { 138 | disablefollowbutton = true 139 | Task { 140 | do { 141 | let result = try await followUser( 142 | did: did) 143 | profile!.viewer?.following = result.uri 144 | } catch { 145 | self.error = error.localizedDescription 146 | } 147 | disablefollowbutton = false 148 | } 149 | } 150 | var feedarray: [FeedDefsFeedViewPost] { 151 | switch selectedtab { 152 | case 1: 153 | return self.authorfeed.feed 154 | case 2: 155 | return likedposts.feed 156 | default: 157 | return self.authorfeed.feed.filter { 158 | return $0.post.record.reply == nil || $0.reason != nil 159 | } 160 | } 161 | } 162 | var isfollowing: Bool { 163 | return profile?.viewer?.following != nil 164 | } 165 | var followedby: Bool { 166 | return profile?.viewer?.followedBy != nil 167 | } 168 | var followbutton: some View { 169 | Button(isfollowing ? "\(Image(systemName: "checkmark")) Following" : "\(Image(systemName: "plus")) Follow") { 170 | isfollowing ? unfollow() : follow() 171 | } 172 | .buttonStyle(.borderedProminent) 173 | .tint(isfollowing ? Color(.controlColor) : Color.accentColor) 174 | .disabled(disablefollowbutton) 175 | } 176 | var unblockbutton: some View { 177 | Button("Unblock") { 178 | isblockalertPresented = true 179 | } 180 | .disabled(disableblockbutton) 181 | } 182 | private func load() async { 183 | loading = true 184 | await getProfile() 185 | if profile?.viewer?.blocking == nil { 186 | await getFeed() 187 | await getLikes() 188 | } 189 | loading = false 190 | } 191 | var header: some View { 192 | Group { 193 | if let banner = profile?.banner { 194 | Button { 195 | GlobalViewModel.shared.preview = URL(string: banner) 196 | } label: { 197 | AsyncImage(url: URL(string: banner)) { image in 198 | image 199 | .resizable() 200 | .scaledToFill() 201 | .frame(height: 200) 202 | .clipped() 203 | } placeholder: { 204 | ProgressView() 205 | .frame(height: 200) 206 | .frame(maxWidth: .infinity, alignment: .center) 207 | } 208 | .blur(radius: profile!.viewer?.blocking == nil ? 0 : 30, opaque: true) 209 | } 210 | .buttonStyle(.plain) 211 | 212 | } 213 | else { 214 | Color.accentColor.frame(height: 200) 215 | } 216 | } 217 | .frame(height: 240, alignment: .topLeading) 218 | .overlay(alignment: .bottomLeading) { 219 | HStack(spacing: 0) { 220 | Button { 221 | GlobalViewModel.shared.preview = URL(string: profile?.avatar ?? "") 222 | } label: { 223 | AvatarView(url: profile?.avatar, size: 80, blur: profile!.viewer?.blocking != nil) 224 | .overlay( 225 | Circle() 226 | .stroke(Color.white, lineWidth: 4) 227 | .frame(width: 80, height: 80) 228 | ) 229 | .padding(.leading) 230 | } 231 | .buttonStyle(.plain) 232 | 233 | Spacer() 234 | Group { 235 | if profile!.did != Client.shared.did { 236 | if profile!.viewer?.blocking == nil { 237 | followbutton 238 | } 239 | else { 240 | unblockbutton 241 | } 242 | } 243 | Menu { 244 | ShareLink(item: URL(string: "https://staging.bsky.app/profile/\(profile!.handle)")!) 245 | if profile!.did != Client.shared.did && profile!.viewer?.blocking == nil { 246 | Button("Block") { 247 | isblockalertPresented = true 248 | } 249 | } 250 | 251 | } label: { 252 | Label("Details", systemImage: "ellipsis") 253 | .labelStyle(.iconOnly) 254 | .contentShape(Rectangle()) 255 | } 256 | .menuStyle(.borderlessButton) 257 | .menuIndicator(.hidden) 258 | .fixedSize() 259 | .foregroundColor(.secondary) 260 | .hoverHand() 261 | } 262 | .padding(.top, 30) 263 | .padding(.trailing) 264 | } 265 | } 266 | .padding(.bottom, 2) 267 | } 268 | var blockedDescription: some View { 269 | ZStack(alignment: .leading) { 270 | RoundedRectangle(cornerRadius: 10) 271 | .opacity(0.05) 272 | Text("\(Image(systemName: "exclamationmark.triangle")) Account Blocked") 273 | .padding(3) 274 | } 275 | .padding(.trailing, 10) 276 | .padding(.bottom, 2) 277 | } 278 | var description: some View { 279 | Group { 280 | Text(profile!.displayName ?? profile!.handle) 281 | .font(.system(size: 30)) 282 | .foregroundColor(.primary) 283 | if followedby { 284 | Text("Follows you") 285 | .padding(3) 286 | .background { 287 | RoundedRectangle(cornerRadius: 10) 288 | .opacity(0.1) 289 | } 290 | } 291 | Text("@\(profile!.handle)") 292 | .foregroundColor(.secondary) 293 | .padding(.bottom, 2) 294 | if profile!.viewer?.blocking != nil { 295 | blockedDescription 296 | } 297 | else { 298 | HStack { 299 | Button { 300 | path.append(.followers(profile!.handle)) 301 | } label: { 302 | Text("\(profile!.followersCount) \(Text("followers").foregroundColor(.secondary))") 303 | } 304 | .buttonStyle(.plain) 305 | Button { 306 | path.append(.following(profile!.handle)) 307 | } label: { 308 | Text("\(profile!.followsCount) \(Text("following").foregroundColor(.secondary))") 309 | } 310 | .buttonStyle(.plain) 311 | Text("\(profile!.postsCount) \(Text("posts").foregroundColor(.secondary))") 312 | } 313 | if let description = profile!.description, !description.isEmpty { 314 | Text(description) 315 | } 316 | HStack{ 317 | ProfileViewTabs(selectedtab: $selectedtab, tabs: tablist) 318 | } 319 | } 320 | 321 | } 322 | } 323 | 324 | @ViewBuilder var feed: some View { 325 | let feed = feedarray 326 | ForEach(feed) { post in 327 | PostView( 328 | post: post.post, reply: post.reply?.parent.author.handle, repost: post.reason, 329 | path: $path 330 | ) 331 | .padding(.horizontal) 332 | .padding(.top) 333 | .contentShape(Rectangle()) 334 | .onTapGesture { 335 | path.append(.thread(post.post.uri)) 336 | } 337 | .task { 338 | if post == feed.last { 339 | if selectedtab == 2, let cursor = likedposts.cursor { 340 | loading = true 341 | await getLikes(cursor: cursor) 342 | loading = false 343 | } 344 | else if let cursor = authorfeed.cursor { 345 | loading = true 346 | await getFeed(cursor: cursor) 347 | loading = false 348 | } 349 | } 350 | } 351 | PostFooterView(post: post.post, path: $path) 352 | Divider() 353 | } 354 | } 355 | var emptyfeed: some View { 356 | HStack(alignment: .center) { 357 | VStack(alignment: .center) { 358 | Image(systemName: "bubble.left") 359 | .resizable() 360 | .frame(width: 64, height: 64) 361 | .padding(.top) 362 | Text(selectedtab == 2 ? "@\(profile!.handle) doesn't have any likes yet!" : "No posts yet!") 363 | .fontWeight(.semibold) 364 | } 365 | } 366 | .foregroundColor(.secondary) 367 | .frame(maxWidth: .infinity, alignment: .center) 368 | } 369 | var body: some View { 370 | List { 371 | Group { 372 | if profile != nil { 373 | header 374 | Group { 375 | description 376 | } 377 | .padding(.leading, 10) 378 | if profile!.viewer?.blocking == nil { 379 | Divider().frame(height: 2) 380 | .padding(.top, 2) 381 | feed 382 | if loading { 383 | ProgressView().frame(maxWidth: .infinity, alignment: .center) 384 | } 385 | else if feedarray.isEmpty { 386 | emptyfeed 387 | } 388 | } 389 | 390 | } 391 | } 392 | .listRowInsets(.init()) 393 | .listRowSeparator(.hidden) 394 | } 395 | .listStyle(.plain) 396 | .scrollContentBackground(.hidden) 397 | .environment(\.defaultMinListRowHeight, 1) 398 | .navigationTitle(profile?.handle ?? "Profile") 399 | .toolbar { 400 | ToolbarItem(placement: .primaryAction) { 401 | Button { 402 | Task { 403 | await load() 404 | } 405 | } label: { 406 | Image(systemName: "arrow.clockwise") 407 | } 408 | .disabled(loading) 409 | } 410 | } 411 | .alert(error, isPresented: .constant(!error.isEmpty)) { 412 | Button("OK") {self.error = ""} 413 | } 414 | .alert(profile?.viewer?.blocking == nil ? "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours." : "The account will be able to interact with you after unblocking. (You can always block again in the future.)", isPresented: $isblockalertPresented) { 415 | Button(profile?.viewer?.blocking == nil ? "Block" : "Unblock", role: .destructive) { 416 | profile?.viewer?.blocking == nil ? block() : unblock() 417 | } 418 | 419 | } 420 | .task { 421 | await load() 422 | } 423 | } 424 | } 425 | -------------------------------------------------------------------------------- /swiftsky/views/SearchActorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchActorView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct SearchActorSubView: View { 9 | var actor: ActorDefsProfileViewBasic 10 | var body: some View { 11 | HStack(alignment: .top) { 12 | AvatarView(url: actor.avatar, size: 40) 13 | VStack(alignment: .leading) { 14 | let displayname = actor.displayName ?? actor.handle 15 | Text(displayname) 16 | .lineLimit(1) 17 | Text("@\(actor.handle)") 18 | .lineLimit(1) 19 | .foregroundColor(.secondary) 20 | } 21 | } 22 | } 23 | } 24 | struct SearchActorView: View { 25 | @Binding var actorstypeahead: ActorSearchActorsTypeaheadOutput 26 | @State private var scrollViewContentSize: CGSize = .zero 27 | var callback: ((ActorDefsProfileViewBasic) -> ())? 28 | 29 | var body: some View { 30 | VStack(alignment: .leading, spacing: 0) { 31 | ForEach(actorstypeahead.actors) { user in 32 | SearchActorSubView(actor: user) 33 | .padding([.top, .leading], 4) 34 | .frame(maxWidth: .infinity, alignment: .leading) 35 | .contentShape(Rectangle()) 36 | .hoverHand() 37 | .onTapGesture { 38 | callback?(user) 39 | } 40 | } 41 | .frame(width: 250) 42 | .frame(minHeight: 40) 43 | } 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /swiftsky/views/SearchField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchField.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct SearchField: NSViewRepresentable { 9 | var textChanged: (String) async -> () 10 | init(_ textChanged: @escaping (String) async -> ()) { 11 | self.textChanged = textChanged 12 | } 13 | class Coordinator: NSObject, NSSearchFieldDelegate { 14 | var parent: SearchField 15 | var task: Task? = nil 16 | init(_ parent: SearchField) { 17 | self.parent = parent 18 | } 19 | func controlTextDidChange(_ notification: Notification) { 20 | guard let searchField = notification.object as? NSSearchField else { 21 | return 22 | } 23 | self.task?.cancel() 24 | self.task = Task { 25 | await self.parent.textChanged(searchField.stringValue) 26 | } 27 | } 28 | } 29 | func makeNSView(context: Context) -> NSSearchField { 30 | let searchfield = NSSearchField(frame: .zero) 31 | searchfield.delegate = context.coordinator 32 | return searchfield 33 | } 34 | func updateNSView(_ searchField: NSSearchField, context: Context) { 35 | 36 | } 37 | func makeCoordinator() -> Coordinator { 38 | return Coordinator(self) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /swiftsky/views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct SettingsView: View { 9 | var body: some View { 10 | TabView { 11 | GeneralSettingsView().tabItem { 12 | Label("General", systemImage: "gearshape") 13 | } 14 | } 15 | .frame(width: 450) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /swiftsky/views/SidebarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SidebarView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct SidebarView: View { 9 | @EnvironmentObject private var auth: Auth 10 | @EnvironmentObject private var globalviewmodel: GlobalViewModel 11 | @EnvironmentObject private var pushnotifications: PushNotificatios 12 | @EnvironmentObject private var preferences: PreferencesModel 13 | @State private var selection: Navigation.Sidebar? = nil 14 | @State private var path: [Navigation] = [] 15 | @State var compose: Bool = false 16 | @State var replypost: Bool = false 17 | @State private var post: FeedDefsPostView? = nil 18 | @State var searchactors = ActorSearchActorsTypeaheadOutput() 19 | @State var searchpresented = false 20 | @State var preferencesLoading = false 21 | @State var preferencesLoadingError: String? = nil 22 | func load() async { 23 | preferencesLoadingError = nil 24 | if !auth.needAuthorization { 25 | Task { 26 | self.globalviewmodel.profile = try? await actorgetProfile(actor: Client.shared.handle) 27 | } 28 | preferencesLoading = true 29 | do 30 | { 31 | try await preferences.sync() 32 | try await SavedFeedsModel.shared.updateCache() 33 | } catch { 34 | preferencesLoadingError = error.localizedDescription 35 | } 36 | preferencesLoading = false 37 | } 38 | } 39 | var body: some View { 40 | NavigationSplitView { 41 | List(selection: $selection) { 42 | NavigationLink(value: Navigation.Sidebar.profile("")) { 43 | HStack(spacing: 5) { 44 | AvatarView(url: self.globalviewmodel.profile?.avatar, size: 40) 45 | VStack(alignment: .leading, spacing: 0) { 46 | if auth.needAuthorization || self.globalviewmodel.profile == nil { 47 | Text("Sign in") 48 | } 49 | else { 50 | if let displayname = self.globalviewmodel.profile!.displayName { 51 | Text(displayname) 52 | } 53 | Text(self.globalviewmodel.profile!.handle) 54 | .font(.footnote) 55 | .opacity(0.6) 56 | } 57 | 58 | } 59 | } 60 | } 61 | 62 | Section { 63 | NavigationLink(value: Navigation.Sidebar.home) { 64 | Label("Home", systemImage: "house") 65 | } 66 | NavigationLink(value: Navigation.Sidebar.notifications) { 67 | Label("Notifications", systemImage: "bell.badge") 68 | .frame(maxWidth: .infinity, alignment: .leading) 69 | .background(alignment: .trailing) { 70 | let unreadcount = pushnotifications.unreadcount 71 | if unreadcount > 0 { 72 | Circle().fill(.red) 73 | .overlay { 74 | Text("\(unreadcount < 10 ? "\(unreadcount)" : "9+")") 75 | .font(.system(size: 11)) 76 | .foregroundColor(.white) 77 | } 78 | } 79 | } 80 | } 81 | } 82 | Section("Feeds") { 83 | NavigationLink(value: Navigation.Sidebar.discoverfeeds) { 84 | Label("Discover", systemImage: "doc.text.magnifyingglass") 85 | } 86 | ForEach(SavedFeedsModel.shared.pinned) { feed in 87 | NavigationLink(value: Navigation.Sidebar.feed(feed)) { 88 | Label( 89 | title: { Text(feed.displayName) }, 90 | icon: { AvatarView(url: feed.avatar, size: 20, isFeed: true) } 91 | ) 92 | .contextMenu { 93 | Button("Remove from sidebar") { 94 | Task { 95 | await preferences.unpinfeed(uri: feed.uri) 96 | } 97 | } 98 | Button("Delete") { 99 | Task { 100 | await preferences.deletefeed(uri: feed.uri) 101 | } 102 | } 103 | } 104 | } 105 | } 106 | .onMove(perform: { indices, newOffset in 107 | var temp = preferences.pinnedFeeds 108 | temp.move(fromOffsets: indices, toOffset: newOffset) 109 | Task { 110 | await preferences.setSavedFeeds(saved: preferences.savedFeeds, pinned: temp) 111 | } 112 | }) 113 | if preferencesLoading { 114 | ProgressView().frame(maxWidth: .infinity, alignment: .center) 115 | } 116 | if let preferencesLoadingError { 117 | ErrorView(error: preferencesLoadingError) { 118 | Task { 119 | await load() 120 | } 121 | } 122 | } 123 | } 124 | } 125 | .frame(minWidth: 230) 126 | .listStyle(.sidebar) 127 | } detail: { 128 | NavigationStack(path: $path) { 129 | Group { 130 | switch selection { 131 | case .profile: 132 | if let profile = self.globalviewmodel.profile { 133 | ProfileView(did: profile.did, profile: profile, path: $path) 134 | .navigationTitle(profile.handle) 135 | } 136 | else { 137 | EmptyView() 138 | } 139 | 140 | case .home: 141 | HomeView(path: $path) 142 | .navigationTitle("Home") 143 | case .notifications: 144 | NotificationsView(path: $path) 145 | .navigationTitle("Notifications") 146 | case .feed(let feed): 147 | FeedView(model: feed, header: false, path: $path) 148 | .navigationTitle(feed.displayName) 149 | case .discoverfeeds: 150 | DiscoverFeedsView(path: $path) 151 | .navigationTitle("Discover Feeds") 152 | default: 153 | EmptyView() 154 | } 155 | } 156 | .frame(minWidth: 200) 157 | .navigationDestination(for: Navigation.self) { destination in 158 | Group { 159 | switch destination { 160 | case .profile(let did): 161 | ProfileView(did: did, path: $path) 162 | case .thread(let uri): 163 | ThreadView(uri: uri, path: $path) 164 | case .followers(let handle): 165 | FollowersView(handle: handle, path: $path) 166 | .navigationTitle("People following @\(handle)") 167 | case .following(let handle): 168 | FollowsView(handle: handle, path: $path) 169 | .navigationTitle("People followed by @\(handle)") 170 | case .feed(let feed): 171 | FeedView(model: feed, header: true, path: $path) 172 | .navigationTitle(feed.displayName) 173 | } 174 | } 175 | .frame(minWidth: 200) 176 | } 177 | .toolbar { 178 | ToolbarItem(placement: .primaryAction) { 179 | Button { 180 | compose = true 181 | } label: { 182 | Image(systemName: "square.and.pencil") 183 | } 184 | .sheet(isPresented: $compose) { 185 | NewPostView() 186 | .frame(width: 600) 187 | .fixedSize() 188 | } 189 | } 190 | ToolbarItem { 191 | SearchField { search in 192 | if !search.isEmpty { 193 | do { 194 | self.searchactors = try await ActorSearchActorsTypeahead(term: search) 195 | self.searchpresented = !self.searchactors.actors.isEmpty 196 | } catch { 197 | 198 | } 199 | } 200 | else { 201 | self.searchactors = .init() 202 | self.searchpresented = false 203 | } 204 | 205 | } 206 | .frame(width: 150) 207 | .popover(isPresented: $searchpresented, arrowEdge: .bottom) { 208 | SearchActorView(actorstypeahead: self.$searchactors) { user in 209 | path.append(.profile(user.did)) 210 | } 211 | } 212 | } 213 | } 214 | } 215 | } 216 | .quickLookPreview($globalviewmodel.preview) 217 | .handlesExternalEvents(preferring: ["*"], allowing: ["*"]) 218 | .onOpenURL(perform: { url in 219 | let components = URLComponents(url: url, resolvingAgainstBaseURL: false) 220 | let did = components?.queryItems?.first { $0.name == "did" }?.value 221 | guard let did = did else { 222 | return 223 | } 224 | 225 | path.append(.profile(did)) 226 | }) 227 | .onChange(of: selection) { _ in 228 | path.removeLast(path.count) 229 | } 230 | .task { 231 | selection = .home 232 | await load() 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /swiftsky/views/TextViewWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextViewWrapper.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | class NSTextViewSubclass: NSTextView{ 9 | var onPaste: (() -> Void)? = nil 10 | func setPasteAction(action: (() -> Void)?) { 11 | onPaste = action 12 | } 13 | override func readSelection(from pboard: NSPasteboard) -> Bool { 14 | if let types = pboard.types { 15 | for type in types { 16 | if type == .png || type == .tiff { 17 | onPaste?() 18 | return false 19 | } 20 | } 21 | } 22 | return super.readSelection(from: pboard) 23 | } 24 | override var readablePasteboardTypes: [NSPasteboard.PasteboardType] { 25 | [.png, .tiff, .string] 26 | } 27 | } 28 | struct TextViewWrapper: NSViewRepresentable { 29 | @Binding var text: String 30 | var placeholder: String? = nil 31 | var onPaste: (() -> Void)? 32 | init(text: Binding, placeholder: String? = nil, onPaste: (() -> Void)? = nil) { 33 | self._text = text 34 | self.placeholder = placeholder 35 | self.onPaste = onPaste 36 | } 37 | func makeNSView(context: Context) -> NSScrollView { 38 | let textView = NSTextViewSubclass() 39 | textView.setPasteAction(action: onPaste) 40 | textView.autoresizingMask = [.width, .height] 41 | textView.isEditable = true 42 | textView.allowsUndo = true 43 | textView.isContinuousSpellCheckingEnabled = true 44 | textView.drawsBackground = false 45 | textView.delegate = context.coordinator 46 | textView.isRichText = false 47 | textView.font = NSFont.systemFont(ofSize: 20) 48 | if let placeholder { 49 | textView.setValue(NSAttributedString(string: placeholder, attributes: [.foregroundColor: NSColor.secondaryLabelColor, .font: textView.font!]), 50 | forKey: "placeholderAttributedString") 51 | } 52 | let scrollView = NSScrollView() 53 | scrollView.hasVerticalScroller = true 54 | scrollView.documentView = textView 55 | return scrollView 56 | } 57 | 58 | func updateNSView(_ nsView: NSScrollView, context: Context) { 59 | 60 | } 61 | func makeCoordinator() -> Coordinator { 62 | return Coordinator(text: $text) 63 | } 64 | 65 | class Coordinator: NSObject, NSTextViewDelegate { 66 | @Binding private var text: String 67 | var autocomplete: NSPopover 68 | var searchtask: Task? = nil 69 | init(text: Binding) { 70 | self._text = text 71 | self.autocomplete = NSPopover() 72 | self.autocomplete.behavior = .transient 73 | } 74 | func textDidChange(_ notification: Notification) { 75 | searchtask?.cancel() 76 | guard let textView = notification.object as? NSTextView else { return } 77 | 78 | self.text = textView.string 79 | if textView.string.isEmpty { 80 | self.autocomplete.close() 81 | return 82 | } 83 | textView.textStorage?.addAttribute(.foregroundColor, value: NSColor.labelColor, range: NSMakeRange(0, self.text.utf16.count)) 84 | let mentionmatches = try? NSRegularExpression(pattern: "(^|\\s|\\()(@)([a-zA-Z0-9.-]+)(\\b)", options: []) 85 | .matches(in: self.text, range: NSRange(location: 0, length: self.text.utf16.count)) 86 | let urlmatches = try? NSRegularExpression(pattern: "(^|\\s|\\()((https?:\\/\\/[\\S]+)|((?[a-z][a-z0-9]*(\\.[a-z0-9]+)+)[\\S]*))", options: []) 87 | .matches(in: self.text, range: NSRange(location: 0, length: self.text.utf16.count)) 88 | textView.textStorage?.enumerateAttributes(in: NSRange(self.text.startIndex..., in: self.text)) { (attributes, range, stop) in 89 | if attributes[.link] != nil { 90 | textView.textStorage?.removeAttribute(.link, range: range) 91 | } 92 | } 93 | if let urlmatches { 94 | for match in urlmatches { 95 | let nsrange = match.range(at: 2) 96 | if let range = Range(nsrange, in: self.text) { 97 | textView.textStorage?.addAttribute(.link, value: self.text[range], range: nsrange) 98 | } 99 | } 100 | } 101 | 102 | var dismisspopover = true 103 | if let mentionmatches { 104 | for match in mentionmatches { 105 | let nsrange = match.range(at: 3) 106 | textView.textStorage?.addAttribute(.foregroundColor, value: NSColor.linkColor, range: match.range(at: 0)) 107 | let selectedrange = textView.selectedRange() 108 | if let range = Range(nsrange, in: self.text) { 109 | let handle = self.text[range] 110 | if selectedrange.location <= (nsrange.location + nsrange.length) && selectedrange.location >= nsrange.location { 111 | dismisspopover = false 112 | self.searchtask = Task { 113 | let searchactors = try? await ActorSearchActorsTypeahead(limit: 5, term: String(handle)) 114 | if let searchactors { 115 | let searchactorview = SearchActorView(actorstypeahead: .constant(searchactors)) { user in 116 | self.autocomplete.close() 117 | textView.textStorage?.replaceCharacters(in: nsrange, with: "\(user.handle)") 118 | self.text = textView.attributedString().string 119 | } 120 | self.autocomplete.contentViewController = NSHostingController(rootView: searchactorview) 121 | let screenRect = textView.firstRect(forCharacterRange: nsrange, actualRange: nil), //from https://github.com/mchakravarty/CodeEditorView/blob/d403292e5100d51300bd27dbdd2c5b13359e772b/Sources/CodeEditorView/CodeActions.swift#L73 122 | nonEmptyScreenRect = NSRect(origin: screenRect.origin, size: CGSize(width: 1, height: 1)), 123 | windowRect = textView.window!.convertFromScreen(nonEmptyScreenRect) 124 | self.autocomplete.show(relativeTo: textView.enclosingScrollView!.convert(windowRect, from: nil), of: textView.enclosingScrollView!, preferredEdge: .maxY) 125 | } 126 | else { 127 | self.autocomplete.close() 128 | } 129 | } 130 | } 131 | } 132 | 133 | } 134 | } 135 | if dismisspopover { 136 | self.autocomplete.close() 137 | } 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /swiftsky/views/ThreadPostView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThreadPostView.swift 3 | // swiftsky 4 | // 5 | 6 | import QuickLook 7 | import SwiftUI 8 | 9 | struct ThreadPostview: View { 10 | @State var post: FeedDefsPostView 11 | @State var reply: String? 12 | @State var usernamehover: Bool = false 13 | @State var displaynamehover: Bool = false 14 | @State var deletepostfailed = false 15 | @State var deletepost = false 16 | @State var translateavailable = false 17 | @Binding var path: [Navigation] 18 | @StateObject var translateviewmodel = TranslateViewModel() 19 | var load: () async -> () 20 | func delete() { 21 | Task { 22 | do { 23 | let result = try await repoDeleteRecord(uri: post.uri, collection: "app.bsky.feed.post") 24 | if result { 25 | await load() 26 | } 27 | } catch { 28 | deletepostfailed = true 29 | } 30 | } 31 | } 32 | var markdown: String { 33 | var markdown = String() 34 | let rt = RichText(text: post.record.text, facets: post.record.facets) 35 | for segment in rt.segments() { 36 | if let link = segment.link() { 37 | markdown += "[\(segment.text)](\(link))" 38 | } 39 | else if let mention = segment.mention() { 40 | markdown += "[\(segment.text)](swiftsky://profile?did=\(mention))" 41 | } 42 | else { 43 | markdown += segment.text 44 | } 45 | } 46 | return markdown 47 | } 48 | var body: some View { 49 | 50 | VStack(alignment: .leading, spacing: 0) { 51 | HStack(alignment: .top) { 52 | AvatarView(url: post.author.avatar, size: 40) 53 | HStack(alignment: .firstTextBaseline) { 54 | let displayname = post.author.displayName ?? post.author.handle 55 | VStack(alignment: .leading) { 56 | Button { 57 | path.append(.profile(post.author.did)) 58 | } label: { 59 | Text(displayname) 60 | .fontWeight(.semibold) 61 | .underline(usernamehover) 62 | } 63 | .buttonStyle(.plain) 64 | .hoverHand {usernamehover = $0} 65 | .tooltip { 66 | ProfilePreview(did: post.author.did, path: $path) 67 | } 68 | Button { 69 | path.append(.profile(post.author.did)) 70 | } label: { 71 | Text("@\(post.author.handle)") 72 | .foregroundColor(.secondary) 73 | .fontWeight(.semibold) 74 | .underline(displaynamehover) 75 | } 76 | .buttonStyle(.plain) 77 | .onHover { ishovered in 78 | if ishovered { 79 | displaynamehover = true 80 | NSCursor.pointingHand.push() 81 | } else { 82 | displaynamehover = false 83 | NSCursor.pointingHand.pop() 84 | } 85 | } 86 | } 87 | 88 | Spacer() 89 | 90 | Group { 91 | MenuButton { 92 | var items: [MenuItem] = [] 93 | items.append( 94 | MenuItem(title: "Share") { 95 | print("Share") 96 | }) 97 | items.append( 98 | MenuItem(title: "Report") { 99 | print("Report") 100 | }) 101 | if post.author.did == Client.shared.did { 102 | items.append( 103 | MenuItem(title: "Delete") { 104 | deletepost = true 105 | }) 106 | } 107 | return items 108 | } 109 | .frame(width: 30, height: 30) 110 | .contentShape(Rectangle()) 111 | .onHover { ishovered in 112 | if ishovered { 113 | NSCursor.pointingHand.push() 114 | } else { 115 | NSCursor.pointingHand.pop() 116 | } 117 | } 118 | } 119 | } 120 | } 121 | 122 | if !post.record.text.isEmpty { 123 | Text(.init(markdown)) 124 | .foregroundColor(.primary) 125 | .textSelection(.enabled) 126 | .padding(.vertical, 4) 127 | if self.translateavailable { 128 | TranslateView(viewmodel: translateviewmodel) 129 | } 130 | } 131 | if let embed = post.embed { 132 | if let images = embed.images { 133 | HStack { 134 | ForEach(images) { image in 135 | Button { 136 | GlobalViewModel.shared.preview = URL(string: image.fullsize) 137 | } label: { 138 | let imagewidth = 600.0 / Double(images.count) 139 | let imageheight = 600.0 / Double(images.count) 140 | AsyncImage(url: URL(string: image.thumb)) { image in 141 | image 142 | .resizable() 143 | .aspectRatio(contentMode: .fit) 144 | .frame(maxWidth: imagewidth, maxHeight: imageheight, alignment: .topLeading) 145 | .clipped() 146 | } placeholder: { 147 | ProgressView() 148 | .frame(width: imagewidth, height: imageheight) 149 | } 150 | .padding(.init(top: 5, leading: 0, bottom: 5, trailing: 0)) 151 | .cornerRadius(15) 152 | } 153 | .buttonStyle(.plain) 154 | } 155 | } 156 | } 157 | if let record: EmbedRecordViewRecord = embed.record { 158 | EmbedPostView(embedrecord: record, path: $path) 159 | .onTapGesture { 160 | path.append(.thread(record.uri)) 161 | } 162 | } 163 | if let external = embed.external { 164 | EmbedExternalView(record: external) 165 | } 166 | } 167 | Text( 168 | "\(Text(post.indexedAt, style: .time)) · \(Text(post.indexedAt, style: .date))" 169 | ) 170 | .foregroundColor(.secondary) 171 | .padding(.bottom, 6) 172 | } 173 | .onAppear { 174 | if translateviewmodel.text.isEmpty && !post.record.text.isEmpty { 175 | if post.record.text.languageCode != GlobalViewModel.shared.systemLanguageCode { 176 | translateavailable = true 177 | } 178 | translateviewmodel.text = post.record.text 179 | } 180 | } 181 | .alert("Failed to delete post, please try again.", isPresented: $deletepostfailed, actions: {}) 182 | .alert("Are you sure?", isPresented: $deletepost) { 183 | Button("Cancel", role: .cancel) {} 184 | Button("Delete", role: .destructive) { 185 | self.delete() 186 | } 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /swiftsky/views/ThreadView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThreadView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct ThreadView: View { 9 | var uri: String 10 | @State var error: String? 11 | @State var threadviewpost: FeedGetPostThreadThreadViewPost? = nil 12 | @State var parents: [FeedGetPostThreadThreadViewPost] = [] 13 | @State var replypresented: Bool = false 14 | @Binding var path: [Navigation] 15 | @StateObject private var globalmodel = GlobalViewModel.shared 16 | func load() async { 17 | threadviewpost = nil 18 | parents = [] 19 | do { 20 | let result = try await getPostThread(uri: self.uri) 21 | if let thread = result.thread { 22 | self.threadviewpost = thread 23 | var currentparent = thread.parent 24 | while let parent = currentparent { 25 | parents.append(parent) 26 | currentparent = parent.parent 27 | } 28 | parents.reverse() 29 | } 30 | } catch { 31 | if let error = error as? xrpcErrorDescription { 32 | self.error = error.message! 33 | return 34 | } 35 | self.error = error.localizedDescription 36 | } 37 | } 38 | var parentPosts: some View { 39 | ForEach(parents) { parent in 40 | if let parentpost = parent.post { 41 | PostView(post: parentpost, reply: parent.parent?.post?.author.handle, path: $path) 42 | .padding([.top, .horizontal]) 43 | .contentShape(Rectangle()) 44 | .onTapGesture { 45 | path.append(.thread(parentpost.uri)) 46 | } 47 | PostFooterView(bottompadding: false, post: parentpost, path: $path) 48 | } 49 | } 50 | } 51 | var body: some View { 52 | ScrollViewReader { proxy in 53 | List { 54 | Group { 55 | if let viewpost = threadviewpost?.post { 56 | parentPosts 57 | .background(alignment: .topLeading) { 58 | Color(NSColor.quaternaryLabelColor) 59 | .frame(width: 4) 60 | .padding(.leading, 35) 61 | } 62 | ThreadPostview( 63 | post: viewpost, reply: threadviewpost?.parent?.post?.author.handle, path: $path, 64 | load: load 65 | ) 66 | .padding(.horizontal) 67 | .padding(.top, parents.isEmpty ? 0 : 5) 68 | .background(alignment: .topLeading) { 69 | if !parents.isEmpty { 70 | Color(NSColor.quaternaryLabelColor) 71 | .frame(width: 4, height: 15) 72 | .padding(.leading, 35) 73 | } 74 | } 75 | PostFooterView(leadingpadding: 15, post: viewpost, path: $path) 76 | Divider() 77 | HStack { 78 | AvatarView(url:globalmodel.profile?.avatar,size: 40) 79 | .padding(.leading) 80 | .padding([.vertical, .trailing], 5) 81 | 82 | Text("Reply to @\(viewpost.author.handle)") 83 | .font(.system(size: 15)) 84 | .opacity(0.9) 85 | Spacer() 86 | } 87 | .hoverHand() 88 | .onTapGesture { 89 | replypresented = true 90 | } 91 | Divider() 92 | .id(viewpost.cid) 93 | if let replies = threadviewpost?.replies { 94 | ForEach(replies) { post in 95 | if let post = post.post { 96 | PostView(post: post, reply: viewpost.author.handle, path: $path) 97 | .padding([.top, .horizontal]) 98 | .contentShape(Rectangle()) 99 | .onTapGesture { 100 | path.append(.thread(post.uri)) 101 | } 102 | PostFooterView(post: post, path: $path) 103 | Divider() 104 | } 105 | 106 | } 107 | } 108 | } else { 109 | if error == nil { 110 | ProgressView().frame(maxWidth: .infinity, alignment: .center) 111 | } 112 | } 113 | } 114 | .listRowInsets(EdgeInsets()) 115 | .listRowSeparator(.hidden) 116 | } 117 | .sheet(isPresented: $replypresented) { 118 | NewPostView(post: threadviewpost?.post) 119 | .frame(width: 600) 120 | .fixedSize() 121 | } 122 | .alert(error ?? "", isPresented: .constant(error != nil)) { 123 | Button("OK") { 124 | path.removeLast() 125 | } 126 | } 127 | .scrollContentBackground(.hidden) 128 | .environment(\.defaultMinListRowHeight, 1) 129 | .listStyle(.plain) 130 | .navigationTitle( 131 | threadviewpost?.post != nil ? "\(threadviewpost!.post!.author.handle)'s post" : "Post" 132 | ) 133 | .task { 134 | await load() 135 | if let post = threadviewpost?.post { 136 | DispatchQueue.main.async { 137 | proxy.scrollTo(post.cid) 138 | } 139 | } 140 | } 141 | .toolbar { 142 | ToolbarItemGroup(placement: .primaryAction) { 143 | if let post = threadviewpost?.post { 144 | let uri = AtUri(uri: post.uri) 145 | ShareLink(item: URL(string: "https://staging.bsky.app/profile/\(post.author.handle)/post/\(uri.rkey)")!) 146 | } 147 | Button { 148 | Task { 149 | await load() 150 | if let post = threadviewpost?.post { 151 | DispatchQueue.main.async { 152 | proxy.scrollTo(post.cid) 153 | } 154 | } 155 | } 156 | } label: { 157 | Image(systemName: "arrow.clockwise") 158 | } 159 | 160 | } 161 | } 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /swiftsky/views/ToolTipView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolTipView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct TooltipModifier: ViewModifier { 9 | var content: TootipContent 10 | @State private var contenthovered = false 11 | @State private var isPresented = false 12 | @State private var work: DispatchWorkItem? 13 | init(@ViewBuilder content: @escaping () -> TootipContent) { 14 | self.content = content() 15 | } 16 | private func onHover(_ hovered: Bool) { 17 | self.work?.cancel() 18 | self.work = DispatchWorkItem(block: { 19 | self.isPresented = self.contenthovered 20 | }) 21 | contenthovered = hovered 22 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: work!) 23 | } 24 | func body(content: Content) -> some View { 25 | content 26 | .onHover { 27 | onHover($0) 28 | } 29 | .popover(isPresented: $isPresented, arrowEdge: .bottom) { 30 | self.content 31 | .onHover { 32 | onHover($0) 33 | } 34 | } 35 | } 36 | } 37 | extension View { 38 | public func tooltip(@ViewBuilder content: @escaping () -> Content) -> some View where Content : View { 39 | modifier(TooltipModifier(content: content)) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /swiftsky/views/TranslateView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TranslateView.swift 3 | // swiftsky 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct TranslateView: View { 9 | @StateObject var viewmodel: TranslateViewModel 10 | @State var underline = false 11 | var body: some View { 12 | if viewmodel.error.isEmpty { 13 | if viewmodel.translatestatus == 1 { 14 | ProgressView().frame(maxWidth: .infinity, alignment: .center) 15 | } 16 | else { 17 | Button { 18 | viewmodel.translatetext() 19 | } label : { 20 | Text(viewmodel.showtranslated ? "Translated to \(GlobalViewModel.shared.systemLanguage) by Google Translate" : "Translate to \(GlobalViewModel.shared.systemLanguage)") 21 | .underline(underline) 22 | .foregroundColor(Color(NSColor.linkColor)) 23 | .hoverHand { 24 | underline = $0 25 | } 26 | } 27 | .disabled(viewmodel.translatestatus == 1) 28 | .buttonStyle(.plain) 29 | if viewmodel.showtranslated && !viewmodel.translatedtext.isEmpty { 30 | Text(.init(viewmodel.translatedtext)) 31 | .padding(.bottom, 3) 32 | } 33 | } 34 | } 35 | else { 36 | Group { 37 | Text("Error: \(viewmodel.error)") 38 | Button("\(Image(systemName: "arrow.clockwise")) Retry") { 39 | viewmodel.error = "" 40 | viewmodel.translatetext() 41 | } 42 | .buttonStyle(.borderedProminent) 43 | .controlSize(.large) 44 | } 45 | .frame(maxWidth: .infinity, alignment: .center) 46 | } 47 | } 48 | } 49 | --------------------------------------------------------------------------------