├── .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 | 
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------