├── .editorconfig ├── .gitignore ├── CODE_OF_CONDUCT.md ├── Configurations ├── Module.xcconfig ├── Multipass.xcconfig ├── Project-Debug.xcconfig ├── Project.xcconfig └── Utility.xcconfig ├── LICENSE ├── Modules ├── BlueskyAPI │ └── Client.swift ├── CompositeSocialService │ ├── BlueskyService.swift │ ├── CompositeClient.swift │ ├── DPoPSigner+JSONWebToken.swift │ ├── MastodonService.swift │ ├── Post+Bluesky.swift │ ├── Post+Mastodon.swift │ ├── SecretStore+Login.swift │ └── Types.swift ├── MastodonAPI │ └── Client.swift ├── Settings │ ├── AccountAddView.swift │ ├── AccountSettingsView.swift │ └── SettingsView.swift ├── Storage │ ├── AccountStore.swift │ ├── DataSource.swift │ ├── SecretStore.swift │ ├── TimelineStore.swift │ └── UserAccountStore.swift ├── Timeline │ ├── AttachmentImageView.swift │ ├── AvatarView.swift │ ├── FeedView.swift │ ├── FeedViewModel.swift │ ├── LoadedImageView.swift │ ├── PostAttachmentView.swift │ ├── PostContentView.swift │ ├── PostStatusView.swift │ └── PostView.swift ├── UIUtility │ ├── MenuActions.swift │ └── Platform.swift └── Utility │ ├── Constants.m │ ├── UserDefaults+Shared.swift │ └── Utility-Bridging-Header.h ├── Multipass.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── CompositeSocialService.xcscheme │ └── Multipass.xcscheme ├── Multipass ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ ├── bluesky.symbolset │ │ ├── Contents.json │ │ └── bluesky.svg │ └── mastodon.clean.fill.symbolset │ │ ├── Contents.json │ │ └── mastodon.clean.fill.svg ├── MainAppView.swift ├── MenuCommands.swift ├── Multipass.entitlements ├── MultipassApp.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json └── Valet+Extensions.swift ├── MultipassTests └── MastodonAPITests.swift ├── MultipassUITests ├── MultipassUITests.swift └── MultipassUITestsLaunchTests.swift ├── README.md ├── User.xcconfig.template ├── assets ├── timeline-ios.png └── timeline-macos.png └── client-metadata.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | User.xcconfig 3 | xcuserdata/ 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at https://mastodon.social/@mattiem. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /Configurations/Module.xcconfig: -------------------------------------------------------------------------------- 1 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO 2 | EXECUTABLE_PREFIX = lib 3 | PRODUCT_NAME = $(TARGET_NAME) 4 | SKIP_INSTALL = YES 5 | 6 | // this is an absurd workaround required because Empire depends on a c library internally 7 | OTHER_SWIFT_FLAGS = $(inherited) -Xcc -fmodule-map-file=$(GENERATED_MODULEMAP_DIR)/clmdb.modulemap 8 | -------------------------------------------------------------------------------- /Configurations/Multipass.xcconfig: -------------------------------------------------------------------------------- 1 | LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks 2 | LD_RUNPATH_SEARCH_PATHS[sdk=macosx*] = $(inherited) @executable_path/../Frameworks 3 | SKIP_INSTALL = NO 4 | CURRENT_PROJECT_VERSION = 1 5 | MARKETING_VERSION = 1.0 6 | -------------------------------------------------------------------------------- /Configurations/Project-Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Configurations/Project.xcconfig" 2 | 3 | DEBUG_INFORMATION_FORMAT = dwarf 4 | ENABLE_TESTABILITY = YES 5 | GCC_OPTIMIZATION_LEVEL = 0 6 | GCC_PREPROCESSOR_DEFINITIONS = DEBUG=1 7 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE 8 | ONLY_ACTIVE_ARCH = YES 9 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG 10 | SWIFT_COMPILATION_MODE = $(inherited) 11 | SWIFT_OPTIMIZATION_LEVEL = -Onone 12 | -------------------------------------------------------------------------------- /Configurations/Project.xcconfig: -------------------------------------------------------------------------------- 1 | #include "../User.xcconfig" 2 | 3 | APP_GROUP[sdk=macosx*] = $(DEVELOPMENT_TEAM).$(BUNDLE_ID_PREFIX).Multipass 4 | APP_GROUP[sdk=iphoneos*] = group.$(BUNDLE_ID_PREFIX).app.Multipass 5 | DEAD_CODE_STRIPPING = YES 6 | KEYCHAIN_ACCESS_GROUP = $(AppIdentifierPrefix)$(BUNDLE_ID_PREFIX).Multipass 7 | PRODUCT_BUNDLE_IDENTIFIER = $(BUNDLE_ID_PREFIX).$(TARGET_NAME) 8 | SWIFT_VERSION = 6.0 9 | MACOSX_DEPLOYMENT_TARGET = 15.0 10 | IPHONEOS_DEPLOYMENT_TARGET = 18.0 11 | XROS_DEPLOYMENT_TARGET = 2.0 12 | SUPPORTED_PLATFORMS = macosx iphonesimulator iphoneos xrsimulator xros 13 | TARGETED_DEVICE_FAMILY = 1,2,7 14 | 15 | -------------------------------------------------------------------------------- /Configurations/Utility.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Configurations/Module.xcconfig" 2 | 3 | SWIFT_OBJC_BRIDGING_HEADER = Modules/Utility/Utility-Bridging-Header.h 4 | 5 | GCC_PREPROCESSOR_DEFINITIONS = $(inherited) APP_GROUP=$(APP_GROUP) APP_IDENTIFIER_PREFIX=$(DEVELOPMENT_TEAM) 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Matt Massicotte 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /Modules/BlueskyAPI/Client.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ATAT 3 | 4 | enum ClientError: Error { 5 | case malformedURL(URLComponents) 6 | case unexpectedResponse(URLResponse) 7 | case requestFailed 8 | case invalidArguments 9 | } 10 | 11 | public actor Client: Sendable { 12 | public typealias ResponseProvider = @Sendable (URLRequest) async throws -> (Data, URLResponse) 13 | 14 | private let provider: ResponseProvider 15 | public let host: String 16 | public let account: String 17 | private let decoder = ATJSONDecoder() 18 | 19 | public init(host: String, account: String, provider: @escaping ResponseProvider) { 20 | self.provider = provider 21 | self.host = host 22 | self.account = account 23 | } 24 | 25 | private var baseComponents: URLComponents { 26 | var components = URLComponents() 27 | components.scheme = "https" 28 | components.host = host 29 | 30 | return components 31 | } 32 | 33 | private func load( 34 | apiPath: String, 35 | queryItems: [URLQueryItem] = [], 36 | block: (inout URLRequest) -> Void = { _ in } 37 | ) async throws -> Success { 38 | var components = baseComponents 39 | 40 | components.path = "/xrpc/\(apiPath)" 41 | 42 | components.queryItems = queryItems 43 | 44 | guard let url = components.url else { 45 | throw ClientError.malformedURL(components) 46 | } 47 | 48 | var request = URLRequest(url: url) 49 | 50 | request.setValue("application/json", forHTTPHeaderField: "Accept") 51 | 52 | block(&request) 53 | 54 | let (data, response) = try await provider(request) 55 | 56 | guard 57 | let httpResponse = response as? HTTPURLResponse, 58 | httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 59 | else { 60 | print("unexpected data:", String(decoding: data, as: UTF8.self)) 61 | print("response:", response) 62 | 63 | throw ClientError.unexpectedResponse(response) 64 | } 65 | 66 | 67 | return try decoder.decode(Success.self, from: data) 68 | } 69 | } 70 | 71 | public struct Credentials: Hashable, Codable, Sendable { 72 | public let identifier: String 73 | public let password: String 74 | 75 | public init(identifier: String, password: String) { 76 | self.identifier = identifier 77 | self.password = password 78 | } 79 | } 80 | 81 | public enum AccountStatus: String, Decodable, Hashable, Sendable { 82 | case takenDown = "takendown" 83 | case suspended 84 | case deactivated 85 | } 86 | 87 | public struct CreateSessionResponse: Decodable, Hashable, Sendable { 88 | public let accessJwt: String 89 | public let refreshJwt: String 90 | public let handle: String 91 | public let did: ATProtoDID 92 | public let email: String 93 | public let emailConfirmed: Bool 94 | public let emailAuthFactor: Bool 95 | public let active: Bool 96 | public let status: AccountStatus? 97 | } 98 | 99 | extension Client { 100 | public func createSession(with login: Credentials) async throws -> CreateSessionResponse { 101 | var components = baseComponents 102 | 103 | components.path = "/xrpc/com.atproto.server.createSession" 104 | 105 | guard let url = components.url else { 106 | throw ClientError.malformedURL(components) 107 | } 108 | 109 | var request = URLRequest(url: url) 110 | 111 | request.httpMethod = "POST" 112 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 113 | request.httpBody = try JSONEncoder().encode(login) 114 | 115 | let (data, _) = try await provider(request) 116 | 117 | return try decoder.decode(CreateSessionResponse.self, from: data) 118 | } 119 | 120 | public func timeline(cursor: String?, limit: Int = 50) async throws -> Bsky.Feed.GetFeedResponse { 121 | try await load( 122 | apiPath: "app.bsky.feed.getTimeline", 123 | queryItems: [ 124 | URLQueryItem(name: "cursor", value: cursor), 125 | URLQueryItem(name: "limit", value: String(limit)) 126 | ] 127 | ) 128 | // 129 | // load(apiPath: "app.bsky.feed.getTimeline") 130 | // var components = baseComponents 131 | // 132 | // components.path = "/xrpc/app.bsky.feed.getTimeline" 133 | // 134 | // guard let url = components.url else { 135 | // throw ClientError.malformedURL(components) 136 | // } 137 | // 138 | // var request = URLRequest(url: url) 139 | // 140 | // request.httpMethod = "GET" 141 | // 142 | // let (data, response) = try await provider(request) 143 | // 144 | // guard 145 | // let httpResponse = response as? HTTPURLResponse, 146 | // httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 147 | // else { 148 | // print("response:", response) 149 | // print(String(decoding: data, as: UTF8.self)) 150 | // throw ClientError.requestFailed 151 | // } 152 | // 153 | // return try decoder.decode(Bsky.Feed.GetFeedResponse.self, from: data) 154 | } 155 | 156 | public func likePost(cid: ATProtoCID, uri: ATProtoURI) async throws { 157 | var components = baseComponents 158 | 159 | components.path = "/xrpc/com.atproto.repo.createRecord" 160 | 161 | guard let url = components.url else { 162 | throw ClientError.malformedURL(components) 163 | } 164 | 165 | let like = Bsky.Feed.Like( 166 | createdAt: .now, 167 | subject: Bsky.Repo.StrongRef( 168 | cid: cid, 169 | uri: uri 170 | ) 171 | ) 172 | 173 | let createRecord = Bsky.Repo.CreateRecord.Request( 174 | repo: account, 175 | collection: .feedLike, 176 | record: .feedLike(like) 177 | ) 178 | 179 | var request = URLRequest(url: url) 180 | 181 | request.httpMethod = "POST" 182 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 183 | request.setValue("application/json", forHTTPHeaderField: "Accept") 184 | request.httpBody = try ATJSONEncoder().encode(createRecord) 185 | 186 | let (data, _) = try await provider(request) 187 | 188 | print(String(decoding: data, as: UTF8.self)) 189 | 190 | _ = try decoder.decode(Bsky.Repo.CreateRecord.Response.self, from: data) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /Modules/CompositeSocialService/BlueskyService.swift: -------------------------------------------------------------------------------- 1 | import CryptoKit 2 | import Foundation 3 | 4 | import ATResolve 5 | import BlueskyAPI 6 | import OAuthenticator 7 | import Storage 8 | 9 | public struct BlueskyAccountDetails: Codable, Hashable, Sendable { 10 | /// This is the user's PDS 11 | public let host: String 12 | public let account: String 13 | } 14 | 15 | enum BlueskyServiceError: Error { 16 | case pdsResolutionFailed(String) 17 | case uriMissing 18 | } 19 | 20 | public actor BlueskyService: SocialService { 21 | private static let dpopKey = "Bluesky DPoP Key" 22 | public static let clientMetadataEndpoint = "https://downloads.chimehq.com/com.chimehq.Multipass/client-metadata.json" 23 | 24 | let clientTask: Task 25 | 26 | public init( 27 | with provider: @escaping URLResponseProvider, 28 | authServer: String, 29 | pds: String? = nil, 30 | account: String, 31 | secretStore: SecretStore 32 | ) { 33 | self.clientTask = Task { 34 | let loginStore = secretStore.loginStore(for: "Bluesky OAuth") 35 | 36 | let key = try await Self.loadDPoPKey(with: secretStore) 37 | 38 | // these three steps should be done on account creation 39 | let clientConfig = try await ClientMetadata.load(for: Self.clientMetadataEndpoint, provider: provider) 40 | let serverConfig = try await ServerMetadata.load(for: authServer, provider: provider) 41 | 42 | // this is necessary because ?? doesn't work with async calls appearently? 43 | let resolvedPDS: String 44 | 45 | if let pds { 46 | resolvedPDS = pds 47 | } else { 48 | resolvedPDS = try await Self.resolve(handle: account) 49 | } 50 | 51 | let tokenHandling = Bluesky.tokenHandling( 52 | account: account, 53 | server: serverConfig, 54 | jwtGenerator: DPoPSigner.JSONWebTokenGenerator(dpopKey: key) 55 | ) 56 | 57 | let config = Authenticator.Configuration( 58 | appCredentials: clientConfig.credentials, 59 | loginStorage: loginStore, 60 | tokenHandling: tokenHandling 61 | ) 62 | 63 | let authenticator = Authenticator(config: config) 64 | 65 | return BlueskyAPI.Client(host: resolvedPDS, account: account, provider: authenticator.responseProvider) 66 | } 67 | } 68 | 69 | private static func resolve(handle: String) async throws -> String { 70 | let resolver = ATResolver() 71 | 72 | let details = try await resolver.resolveHandle(handle) 73 | 74 | guard let pdsURL = details?.personalDataServerURL else { 75 | print("failed to resolve PDS for \(handle)") 76 | 77 | throw BlueskyServiceError.pdsResolutionFailed(handle) 78 | } 79 | 80 | guard 81 | let components = URLComponents(url: pdsURL, resolvingAgainstBaseURL: false), 82 | let host = components.host 83 | else { 84 | print("failed to get pds url components \(handle)") 85 | 86 | throw BlueskyServiceError.pdsResolutionFailed(handle) 87 | } 88 | 89 | return host 90 | } 91 | 92 | private static func loadDPoPKey(with store: SecretStore) async throws -> DPoPKey { 93 | do { 94 | if let data = try await store.read(Self.dpopKey) { 95 | return try JSONDecoder().decode(DPoPKey.self, from: data) 96 | } 97 | } catch { 98 | print("failed to get existing DPoP key", error) 99 | } 100 | 101 | let key = DPoPKey.P256() 102 | 103 | let keyData = try JSONEncoder().encode(key) 104 | 105 | try await store.write(keyData, Self.dpopKey) 106 | 107 | return key 108 | } 109 | 110 | private var client: BlueskyAPI.Client { 111 | get async throws { 112 | try await clientTask.value 113 | } 114 | } 115 | 116 | public func timeline(from position: ServicePosition, newer: Bool) async throws -> [Post] { 117 | assert(newer == true, "older isn't supported yet") 118 | let response = try await client.timeline(cursor: position.bluesky) 119 | 120 | return response.feed.compactMap { entry in 121 | if entry.reply != nil { 122 | return nil 123 | } 124 | 125 | return Post(entry) 126 | } 127 | } 128 | 129 | public func likePost(_ post: Post) async throws { 130 | if post.source != .bluesky { 131 | return 132 | } 133 | 134 | guard let uri = post.uri else { 135 | throw BlueskyServiceError.uriMissing 136 | } 137 | 138 | _ = try await client.likePost(cid: post.identifier, uri: uri) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Modules/CompositeSocialService/CompositeClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | import OAuthenticator 4 | import Storage 5 | 6 | public typealias URLResponseProvider = OAuthenticator.URLResponseProvider 7 | 8 | public protocol SocialService: Sendable { 9 | func timeline(from position: ServicePosition, newer: Bool) async throws -> [Post] 10 | func likePost(_ post: Post) async throws 11 | } 12 | 13 | public struct CompositeClient { 14 | private let secretStore: SecretStore 15 | public let services: [SocialService] 16 | 17 | public init(secretStore: SecretStore, services: [SocialService]) { 18 | self.secretStore = secretStore 19 | self.services = services 20 | } 21 | } 22 | 23 | extension CompositeClient: SocialService { 24 | public func timeline(from position: ServicePosition, newer: Bool) async throws -> [Post] { 25 | return try await withThrowingTaskGroup(of: [Post].self) { group in 26 | for service in services { 27 | group.addTask { 28 | do { 29 | return try await service.timeline(from: position, newer: newer) 30 | } catch { 31 | print("failed to load timeline: \(service), \(error)") 32 | return [] 33 | } 34 | } 35 | } 36 | 37 | var posts = [Post]() 38 | 39 | for try await result in group { 40 | posts += result 41 | } 42 | 43 | return posts 44 | } 45 | } 46 | 47 | public func likePost(_ post: Post) async throws { 48 | try await withThrowingTaskGroup { group in 49 | for service in services { 50 | group.addTask { 51 | try await service.likePost(post) 52 | } 53 | } 54 | 55 | for try await _ in group { 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Modules/CompositeSocialService/DPoPSigner+JSONWebToken.swift: -------------------------------------------------------------------------------- 1 | import CryptoKit 2 | import Foundation 3 | 4 | import Jot 5 | import OAuthenticator 6 | 7 | struct DPoPTokenClaims : JSONWebTokenPayload { 8 | // standard claims 9 | let iss: String? 10 | let jti: String? 11 | let iat: Date? 12 | let exp: Date? 13 | 14 | // custom claims 15 | let htm: String? 16 | let htu: String? 17 | let nonce: String? 18 | let ath: String? 19 | 20 | init(authorizationServerIssuer: String?, accessTokenHash: String?, httpMethod: String, requestEndpoint: String, nonce: String?) { 21 | let now = Date.now 22 | 23 | self.iss = authorizationServerIssuer 24 | self.jti = UUID().uuidString 25 | self.exp = now.addingTimeInterval(60.0) 26 | self.iat = now 27 | self.htm = httpMethod 28 | self.htu = requestEndpoint 29 | self.nonce = nonce 30 | self.ath = accessTokenHash 31 | } 32 | } 33 | 34 | extension DPoPSigner { 35 | static func JSONWebTokenGenerator(dpopKey: DPoPKey) -> DPoPSigner.JWTGenerator { 36 | let id = dpopKey.id.uuidString 37 | 38 | return { params in 39 | let key = try dpopKey.p256PrivateKey 40 | 41 | let jwk = JSONWebKey(p256Key: key.publicKey) 42 | 43 | let newToken = JSONWebToken( 44 | header: JSONWebTokenHeader( 45 | algorithm: .ES256, 46 | type: params.keyType, 47 | keyId: id, 48 | jwk: jwk 49 | ), 50 | payload: DPoPTokenClaims( 51 | authorizationServerIssuer: params.issuingServer, 52 | accessTokenHash: params.tokenHash, 53 | httpMethod: params.httpMethod, 54 | requestEndpoint: params.requestEndpoint, 55 | nonce: params.nonce 56 | ) 57 | ) 58 | 59 | return try newToken.encode(with: key) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Modules/CompositeSocialService/MastodonService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | import MastodonAPI 4 | import OAuthenticator 5 | import Reblog 6 | import Storage 7 | 8 | public struct MastodonAccountDetails: Codable, Hashable, Sendable { 9 | public let host: String 10 | public let account: String 11 | } 12 | 13 | public struct MastodonService: SocialService { 14 | private static let appRegistrationKey = "Mastodon App Registration" 15 | 16 | let clientTask: Task 17 | let host: String 18 | private let provider: URLResponseProvider 19 | 20 | public init(with provider: @escaping URLResponseProvider, host: String, secretStore: SecretStore) { 21 | let params = Mastodon.UserTokenParameters( 22 | host: host, 23 | clientName: "Multipass", 24 | redirectURI: "MultipassApp://mastodon/oauth", 25 | scopes: ["read", "write", "follow", "push"] 26 | ) 27 | 28 | self.host = host 29 | self.provider = provider 30 | self.clientTask = Task { 31 | let loginStore = secretStore.loginStore(for: "Mastodon OAuth") 32 | 33 | // this should be done on account creation 34 | let registration = try await Self.registerApplication(parameters: params, store: secretStore, provider: provider) 35 | 36 | let appCreds = AppCredentials( 37 | clientId: registration.clientID, 38 | clientPassword: registration.clientSecret, 39 | scopes: params.scopes, 40 | callbackURL: URL(string: params.redirectURI)! 41 | ) 42 | 43 | let config = Authenticator.Configuration( 44 | appCredentials: appCreds, 45 | loginStorage: loginStore, 46 | tokenHandling: Mastodon.tokenHandling(with: params) 47 | ) 48 | 49 | let authenticator = Authenticator(config: config, urlLoader: provider) 50 | 51 | return MastodonAPI.Client(host: params.host, provider: authenticator.responseProvider) 52 | } 53 | } 54 | 55 | private static func registerApplication( 56 | parameters: Mastodon.UserTokenParameters, 57 | store: SecretStore, 58 | provider: @escaping URLResponseProvider 59 | ) async throws -> Mastodon.AppRegistrationResponse { 60 | do { 61 | if let data = try await store.read(Self.appRegistrationKey) { 62 | return try JSONDecoder().decode(Mastodon.AppRegistrationResponse.self, from: data) 63 | } 64 | } catch { 65 | print("failed to get existing app registration", error) 66 | } 67 | 68 | let appRegistration = try await Mastodon.register(with: parameters, urlLoader: provider) 69 | 70 | let keyData = try JSONEncoder().encode(appRegistration) 71 | 72 | try await store.write(keyData, Self.appRegistrationKey) 73 | 74 | return appRegistration 75 | } 76 | 77 | private var client: MastodonAPI.Client { 78 | get async throws { 79 | try await clientTask.value 80 | } 81 | } 82 | 83 | public func timeline(from position: ServicePosition, newer: Bool) async throws -> [Post] { 84 | // this is kind of mind-bending. Our position is defined as the current loaded window. 85 | // 86 | // If we want newer statuses, then we need to set our minimum to the current maximum. 87 | let minId = newer ? position.mastodon : nil 88 | let maxId = newer ? nil : position.mastodon 89 | 90 | let statusArray = try await client.timeline(minimumId: minId, maximumId: maxId) 91 | let parser = ContentParser() 92 | 93 | return statusArray.compactMap { status -> Post? in 94 | // filter direct relies 95 | if status.inReplyToId != nil { 96 | return nil 97 | } 98 | 99 | return Post(status, host: host, parser: parser) 100 | } 101 | } 102 | 103 | public func likePost(_ post: Post) async throws { 104 | if post.source != .mastodon { 105 | return 106 | } 107 | 108 | _ = try await client.likePost(post.identifier) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Modules/CompositeSocialService/Post+Bluesky.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | import ATAT 4 | 5 | extension Post { 6 | init(_ feedViewPost: Bsky.Feed.FeedViewPost) { 7 | self.init( 8 | content: feedViewPost.text, 9 | source: .bluesky, 10 | date: feedViewPost.post.date, 11 | author: Author(feedViewPost.post.author), 12 | repostingAuthor: feedViewPost.reasonRepost.flatMap { Author($0.by) }, 13 | identifier: feedViewPost.post.cid, 14 | url: feedViewPost.post.url, 15 | uri: feedViewPost.post.uri, 16 | attachments: [], 17 | status: PostStatus( 18 | likeCount: feedViewPost.post.likeCount, 19 | liked: feedViewPost.post.viewer.like != nil, 20 | repostCount: feedViewPost.post.repostCount, 21 | reposted: false 22 | ) 23 | ) 24 | } 25 | } 26 | 27 | extension Author { 28 | init(_ profile: Bsky.Actor.ProfileViewBasic) { 29 | self.init( 30 | name: profile.displayName ?? "", 31 | handle: profile.handle, 32 | avatarURL: profile.avatarURL 33 | ) 34 | } 35 | } 36 | 37 | extension Bsky.Feed.FeedViewPost { 38 | var text: String? { 39 | guard case let .post(post) = post.record else { 40 | return nil 41 | } 42 | 43 | return post.text 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Modules/CompositeSocialService/Post+Mastodon.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | import Reblog 4 | 5 | extension Post { 6 | init?(_ status: Status, host: String, parser: ContentParser) { 7 | let content: String 8 | 9 | do { 10 | let visibleContent = status.reblog?.content ?? status.content 11 | 12 | let components = try parser.parse(visibleContent) 13 | 14 | if case let .link(_, value) = components.first, value.hasPrefix("@") { 15 | return nil 16 | } 17 | 18 | content = parser.renderToString(components) 19 | } catch { 20 | print("failed to process:", status) 21 | return nil 22 | } 23 | 24 | let author = Author( 25 | name: status.account.displayName, 26 | handle: status.account.resolvedUsername(with: host), 27 | avatarURL: URL(string: status.account.avatarStatic) 28 | ) 29 | 30 | let rebloggedAuthor = status.reblog.map { 31 | Author( 32 | name: $0.account.displayName, 33 | handle: $0.account.resolvedUsername(with: host), 34 | avatarURL: URL(string: $0.account.avatarStatic) 35 | ) 36 | } 37 | 38 | let imageCollections = status.mediaAttachments.compactMap { mediaAttachment -> Attachment.Image? in 39 | guard mediaAttachment.type == .image else { return nil } 40 | guard let url = mediaAttachment.url else { return nil } 41 | 42 | return Attachment.Image( 43 | preview: mediaAttachment.previewURL.flatMap { .init(url: $0, size: nil, focus: nil) }, 44 | full: .init(url: url, size: nil, focus: nil), 45 | description: mediaAttachment.description 46 | ) 47 | } 48 | 49 | let attachments = [ 50 | Attachment.images(imageCollections) 51 | ] 52 | 53 | self.init( 54 | content: content, 55 | source: .mastodon, 56 | date: status.createdAt, 57 | author: author, 58 | repostingAuthor: rebloggedAuthor, 59 | identifier: status.id, 60 | url: URL(string: status.uri), 61 | attachments: attachments, 62 | status: PostStatus( 63 | likeCount: status.favorites, 64 | liked: status.favorited ?? false, 65 | repostCount: status.reblogs, 66 | reposted: status.reblogged ?? false 67 | ) 68 | ) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Modules/CompositeSocialService/SecretStore+Login.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | import OAuthenticator 4 | import Storage 5 | 6 | extension SecretStore { 7 | func loginStore(for key: String) -> LoginStorage { 8 | LoginStorage { 9 | guard let data = try await read(key) else { 10 | return nil 11 | } 12 | 13 | return try JSONDecoder().decode(Login.self, from: data) 14 | } storeLogin: { login in 15 | let data = try JSONEncoder().encode(login) 16 | 17 | try await write(data, key) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Modules/CompositeSocialService/Types.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreGraphics 3 | 4 | import Storage 5 | 6 | public struct Author: Hashable, Sendable { 7 | public let name: String 8 | public let handle: String 9 | public let avatarURL: URL? 10 | 11 | public init(name: String, handle: String, avatarURL: URL? = nil) { 12 | self.name = name 13 | self.handle = handle 14 | self.avatarURL = avatarURL 15 | } 16 | 17 | public static let placeholder = Author(name: "placeholder", handle: "placeholder") 18 | } 19 | 20 | public enum Attachment: Hashable, Sendable { 21 | public struct ImageSpecifier: Hashable, Sendable { 22 | public let url: URL 23 | public let size: CGSize? 24 | public let focus: CGPoint? 25 | } 26 | 27 | public struct Image: Hashable, Sendable { 28 | public let preview: ImageSpecifier? 29 | public let full: ImageSpecifier 30 | public let description: String? 31 | 32 | public init(preview: ImageSpecifier?, full: ImageSpecifier, description: String?) { 33 | self.preview = preview 34 | self.full = full 35 | self.description = description 36 | } 37 | } 38 | 39 | public struct Link: Hashable, Sendable { 40 | public let preview: ImageSpecifier? 41 | public let description: String? 42 | public let title: String? 43 | public let url: URL 44 | 45 | public init(preview: ImageSpecifier?, description: String?, title: String?, url: URL) { 46 | self.preview = preview 47 | self.description = description 48 | self.title = title 49 | self.url = url 50 | } 51 | } 52 | 53 | case images([Image]) 54 | case link(Link) 55 | } 56 | 57 | public struct PostStatus: Hashable, Sendable { 58 | public let likeCount: Int 59 | public let liked: Bool 60 | public let repostCount: Int 61 | public let reposted: Bool 62 | 63 | public init(likeCount: Int, liked: Bool, repostCount: Int, reposted: Bool) { 64 | self.likeCount = likeCount 65 | self.liked = liked 66 | self.repostCount = repostCount 67 | self.reposted = reposted 68 | } 69 | 70 | public static let placeholder = PostStatus(likeCount: 5, liked: false, repostCount: 150, reposted: true) 71 | } 72 | 73 | public struct Post: Hashable, Sendable { 74 | public let content: String? 75 | public let source: DataSource 76 | public let date: Date 77 | public let author: Author 78 | public let repostingAuthor: Author? 79 | public let identifier: String 80 | public let url: URL? 81 | public let uri: String? 82 | public let attachments: [Attachment] 83 | public let status: PostStatus 84 | 85 | // service-specific things 86 | public let blueskyCursor: String? 87 | public var blueskyURI: String? { uri } 88 | public var mastodonStatusId: String? { 89 | guard source == .mastodon else { 90 | return nil 91 | } 92 | 93 | return identifier 94 | } 95 | 96 | public init( 97 | content: String?, 98 | source: DataSource, 99 | date: Date, 100 | author: Author, 101 | repostingAuthor: Author?, 102 | identifier: String, 103 | url: URL?, 104 | uri: String? = nil, 105 | attachments: [Attachment], 106 | status: PostStatus, 107 | blueskyCursor: String? = nil 108 | ) { 109 | self.content = content 110 | self.source = source 111 | self.date = date 112 | self.author = author 113 | self.repostingAuthor = repostingAuthor 114 | self.identifier = identifier 115 | self.url = url 116 | self.uri = uri 117 | self.attachments = attachments 118 | self.status = status 119 | self.blueskyCursor = blueskyCursor 120 | } 121 | 122 | public static let placeholder = Post( 123 | content: "hello", 124 | source: .mastodon, 125 | date: .now, 126 | author: Author.placeholder, 127 | repostingAuthor: nil, 128 | identifier: "abc123", 129 | url: URL(string: "https://example.com")!, 130 | attachments: [], 131 | status: .placeholder 132 | ) 133 | } 134 | 135 | extension Post: Identifiable { 136 | public var id: String { 137 | "\(source)-\(identifier)" 138 | } 139 | } 140 | 141 | extension Post: Comparable { 142 | public static func < (lhs: Post, rhs: Post) -> Bool { 143 | lhs.date < rhs.date 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Modules/MastodonAPI/Client.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | import OAuthenticator 4 | import Reblog 5 | 6 | enum ClientError: Error { 7 | case unexpectedResponse(URLResponse) 8 | case malformedURL(URLComponents) 9 | } 10 | 11 | public struct Client: Sendable { 12 | public typealias ResponseProvider = @Sendable (URLRequest) async throws -> (Data, URLResponse) 13 | 14 | private let provider: ResponseProvider 15 | private let decoder = JSONDecoder() 16 | public let host: String 17 | 18 | public init(host: String, provider: @escaping ResponseProvider) { 19 | self.provider = provider 20 | self.host = host 21 | 22 | let formatter = DateFormatter() 23 | 24 | // 2024-11-15T18:16:35.907Z 25 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" 26 | 27 | decoder.dateDecodingStrategy = .formatted(formatter) 28 | 29 | } 30 | 31 | private var baseComponents: URLComponents { 32 | var components = URLComponents() 33 | components.scheme = "https" 34 | components.host = host 35 | 36 | return components 37 | } 38 | 39 | private func load( 40 | apiPath: String, 41 | queryItems: [URLQueryItem] = [], 42 | block: (inout URLRequest) -> Void = { _ in } 43 | ) async throws -> Success { 44 | var components = baseComponents 45 | 46 | components.path = "/api/v1/\(apiPath)" 47 | 48 | components.queryItems = queryItems 49 | 50 | guard let url = components.url else { 51 | throw ClientError.malformedURL(components) 52 | } 53 | 54 | var request = URLRequest(url: url) 55 | 56 | request.setValue("application/json", forHTTPHeaderField: "Accept") 57 | 58 | block(&request) 59 | 60 | let (data, response) = try await provider(request) 61 | 62 | guard 63 | let httpResponse = response as? HTTPURLResponse, 64 | httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 65 | else { 66 | print("unexpected data:", String(decoding: data, as: UTF8.self)) 67 | print("response:", response) 68 | 69 | throw ClientError.unexpectedResponse(response) 70 | } 71 | 72 | 73 | return try decoder.decode(Success.self, from: data) 74 | } 75 | } 76 | 77 | extension Client { 78 | public func markers(timelines: Set = [.home, .notifications]) async throws -> MarkerResponse { 79 | var urlBuilder = baseComponents 80 | urlBuilder.path = "/api/v1/markers" 81 | urlBuilder.queryItems = [ 82 | URLQueryItem(name: "timeline[]", value: "home"), 83 | URLQueryItem(name: "timeline[]", value: "notifications"), 84 | ] 85 | 86 | guard let url = urlBuilder.url else { 87 | throw ClientError.malformedURL(urlBuilder) 88 | } 89 | 90 | let request = URLRequest(url: url) 91 | 92 | let (data, _) = try await provider(request) 93 | 94 | return try decoder.decode(MarkerResponse.self, from: data) 95 | } 96 | 97 | public func timeline(minimumId: String? = nil, maximumId: String? = nil, limit: Int = 20) async throws -> [Status] { 98 | try await load( 99 | apiPath: "timelines/home", 100 | queryItems: [ 101 | URLQueryItem(name: "min_id", value: minimumId), 102 | URLQueryItem(name: "max_id", value: maximumId), 103 | URLQueryItem(name: "limit", value: String(limit)), 104 | ] 105 | ) 106 | } 107 | 108 | public func likePost(_ id: String) async throws -> Status { 109 | var components = baseComponents 110 | 111 | components.path = "/api/v1/statuses/\(id)/favourite" 112 | 113 | guard let url = components.url else { 114 | throw ClientError.malformedURL(components) 115 | } 116 | 117 | var request = URLRequest(url: url) 118 | 119 | request.httpMethod = "POST" 120 | 121 | let (data, _) = try await provider(request) 122 | 123 | return try decoder.decode(Status.self, from: data) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Modules/Settings/AccountAddView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import CompositeSocialService 4 | import Storage 5 | import UIUtility 6 | 7 | struct AccountAddView: View { 8 | @Environment(UserAccountStore.self) var accountStore 9 | @Environment(\.dismiss) private var dismiss 10 | @State private var details: UserAccountDetails 11 | @State private var adding = false 12 | let source: DataSource 13 | 14 | init(source: DataSource) { 15 | self.source = source 16 | 17 | let defaultHost = switch source { 18 | case .mastodon: 19 | "mastodon.social" 20 | case .bluesky: 21 | "bsky.social" 22 | } 23 | 24 | self._details = State(initialValue: UserAccountDetails(host: defaultHost, user: "me")) 25 | } 26 | 27 | var body: some View { 28 | VStack { 29 | Form { 30 | Text("Service: \(source.rawValue)") 31 | TextField("Host Server", text: $details.host) 32 | .platform_textInputAutocapitalization(.never) 33 | TextField("Account", text: $details.user) 34 | .platform_textInputAutocapitalization(.never) 35 | .autocorrectionDisabled(true) 36 | } 37 | Button("Add") { 38 | addAccount() 39 | }.disabled(adding) 40 | } 41 | .padding() 42 | } 43 | 44 | private func addAccount() { 45 | self.adding = true 46 | 47 | let account = UserAccount(source: source, details: details) 48 | 49 | Task { 50 | do { 51 | try await accountStore.addAccount(account) 52 | } 53 | catch { 54 | print("failed to add account", error) 55 | } 56 | 57 | self.adding = false 58 | dismiss() 59 | } 60 | } 61 | } 62 | 63 | #Preview { 64 | AccountAddView(source: .mastodon) 65 | } 66 | -------------------------------------------------------------------------------- /Modules/Settings/AccountSettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import CompositeSocialService 4 | import Storage 5 | 6 | struct AccountSettingsView: View { 7 | @Environment(UserAccountStore.self) var accountStore 8 | @State private var addingMastodon = false 9 | @State private var addingBluesky = false 10 | @State private var selection = Set() 11 | 12 | var body: some View { 13 | HStack{ 14 | #if os(macOS) 15 | VStack{ 16 | addMasterodonButton 17 | addBlueskyButton 18 | Button("Remove All"){ 19 | Task{ 20 | try await accountStore.removeAllAccounts() 21 | } 22 | } 23 | } 24 | List(selection: $selection) { 25 | accounts 26 | } 27 | .onDeleteCommand { 28 | Task{ 29 | for account in selection { 30 | try? await accountStore.removeAccount(account) 31 | } 32 | } 33 | } 34 | #else 35 | List { 36 | accounts 37 | addMasterodonButton 38 | addBlueskyButton 39 | } 40 | #endif 41 | } 42 | .sheet(isPresented: $addingMastodon) { 43 | AccountAddView(source: .mastodon) 44 | } 45 | .sheet(isPresented: $addingBluesky) { 46 | AccountAddView(source: .bluesky) 47 | } 48 | } 49 | 50 | private var addBlueskyButton: some View { 51 | Button("Add Bluesky", image: ImageResource(name: "bluesky", bundle: Bundle.main)) { 52 | addingBluesky = true 53 | } 54 | } 55 | private var addMasterodonButton: some View { 56 | Button("Add Mastodon", image: ImageResource(name: "mastodon.clean.fill", bundle: Bundle.main)) { 57 | addingMastodon = true 58 | } 59 | } 60 | 61 | private var accounts: some View { 62 | ForEach(accountStore.accounts) { account in 63 | Label { 64 | Text(account.details.user) 65 | Text(account.details.host) 66 | } icon: { 67 | Image(account.source.imageName) 68 | } 69 | .tag(account) 70 | } 71 | .onDelete { idx in 72 | let accounts = accountStore.accounts 73 | Task { 74 | for id in idx { 75 | guard 0.. Data? 5 | public typealias WriteSecret = @Sendable (Data, String) async throws -> Void 6 | 7 | public let read: ReadSecret 8 | public let write: WriteSecret 9 | 10 | public init(read: @escaping ReadSecret, write: @escaping WriteSecret) { 11 | self.read = read 12 | self.write = write 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Modules/Storage/TimelineStore.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import Empire 4 | import Utility 5 | 6 | @IndexKeyRecord("singleKey") 7 | public struct ServicePosition: Hashable, Sendable { 8 | public var singleKey: String = "me" 9 | public var date: Date 10 | public var bluesky: String? 11 | public var mastodon: String? 12 | 13 | public init(date: Date, bluesky: String?, mastodon: String?) { 14 | self.date = date 15 | self.bluesky = bluesky 16 | self.mastodon = mastodon 17 | } 18 | 19 | public static let unknown = ServicePosition(date: .now, bluesky: nil, mastodon: nil) 20 | } 21 | 22 | @MainActor 23 | @Observable 24 | public final class TimelineStore { 25 | public var maximumPosts: Int = 100 26 | 27 | @ObservationIgnored 28 | private let store: Store? 29 | 30 | public init() { 31 | let url = URL 32 | .cachesDirectory 33 | .appending(path: "timeline") 34 | 35 | print("timeline store url: ", url) 36 | 37 | do { 38 | try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) 39 | 40 | let database = try Database(url: url) 41 | self.store = Store(database: database) 42 | } catch { 43 | self.store = nil 44 | print("unable to initialize store:", error) 45 | } 46 | } 47 | 48 | public var position: ServicePosition? { 49 | get { 50 | try! store?.select(key: Tuple("me")) 51 | } 52 | set { 53 | if let value = newValue { 54 | try! store?.insert(value) 55 | return 56 | } 57 | 58 | try! store?.withTransaction { ctx in 59 | try ServicePosition.delete(in: ctx, singleKey: "me") 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Modules/Storage/UserAccountStore.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// This might need to be further customized at some point 4 | public struct UserAccountDetails: Codable, Hashable, Sendable { 5 | public var host: String 6 | public var user: String 7 | 8 | public init(host: String, user: String) { 9 | self.host = host 10 | self.user = user 11 | } 12 | } 13 | 14 | public struct UserAccount: Codable, Sendable, Hashable { 15 | public var source: DataSource 16 | public var details: UserAccountDetails 17 | 18 | public init(source: DataSource, details: UserAccountDetails) { 19 | self.source = source 20 | self.details = details 21 | } 22 | } 23 | 24 | extension UserAccount: Identifiable { 25 | public var id: String { 26 | "\(source):\(details.host):\(details.user)" 27 | } 28 | } 29 | 30 | @MainActor 31 | @Observable 32 | public final class UserAccountStore { 33 | private static let accountsKey = "Accounts" 34 | 35 | public private(set) var accounts: [UserAccount] 36 | 37 | @ObservationIgnored 38 | private let secretStore: SecretStore 39 | 40 | public init(secretStore: SecretStore) { 41 | self.secretStore = secretStore 42 | self.accounts = [] 43 | 44 | Task { 45 | do { 46 | guard let data = try await secretStore.read(Self.accountsKey) else { 47 | self.accounts = [] 48 | return 49 | } 50 | 51 | self.accounts = try JSONDecoder().decode([UserAccount].self, from: data) 52 | } catch { 53 | print("failed to decode accounts:", error) 54 | 55 | self.accounts = [] 56 | } 57 | } 58 | } 59 | 60 | public func addAccount(_ account: UserAccount) async throws { 61 | self.accounts.append(account) 62 | 63 | try await writeToSecretStore() 64 | } 65 | 66 | public func removeAllAccounts() async throws { 67 | self.accounts.removeAll() 68 | 69 | try await writeToSecretStore() 70 | } 71 | 72 | public func removeAccount(_ account: UserAccount) async throws { 73 | accounts.removeAll { storedAccount in 74 | account == storedAccount 75 | } 76 | try await writeToSecretStore() 77 | } 78 | 79 | private func writeToSecretStore() async throws { 80 | let data = try JSONEncoder().encode(accounts) 81 | try await secretStore.write(data, Self.accountsKey) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Modules/Timeline/AttachmentImageView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AttachmentImageView: View { 4 | let url: URL? 5 | 6 | var body: some View { 7 | LoadedImageView(url: url, placeholderName: "photo.fill") 8 | .frame(idealWidth: 226, maxWidth: 400, idealHeight: 226, maxHeight: 400) 9 | .border(Color.gray) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Modules/Timeline/AvatarView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AvatarView: View { 4 | let url: URL? 5 | 6 | var body: some View { 7 | AsyncImage(url: url) { image in 8 | image 9 | .resizable() 10 | .aspectRatio(contentMode: .fit) 11 | } placeholder: { 12 | Image(systemName: "person.fill") 13 | } 14 | .frame(width: 40.0) 15 | 16 | } 17 | } 18 | 19 | #Preview { 20 | AvatarView(url: nil) 21 | } 22 | -------------------------------------------------------------------------------- /Modules/Timeline/FeedView.swift: -------------------------------------------------------------------------------- 1 | import CompositeSocialService 2 | import StableView 3 | import Storage 4 | import SwiftUI 5 | import UIUtility 6 | 7 | public struct FeedView: View { 8 | @State private var model: FeedViewModel 9 | @Environment(UserAccountStore.self) private var accountStore 10 | @Environment(\.openURL) private var openURL 11 | @State private var newPosition: ScrollPosition = .init(idType: Post.ID.self) 12 | 13 | public init(secretStore: SecretStore, timelineStore: TimelineStore) { 14 | self._model = State( 15 | wrappedValue: FeedViewModel( 16 | secretStore: secretStore, 17 | timelineStore: timelineStore 18 | ) 19 | ) 20 | } 21 | 22 | public var body: some View { 23 | Text("Items Above: \(model.aboveCount)") 24 | AnchoredList(items: model.posts, position: $model.positionAnchor) { post, row in 25 | // List(model.posts, id: \.self) { post in 26 | PostView( 27 | post: post, 28 | actionHandler: { action in 29 | model.handlePostAction(action: action, post: post) 30 | } 31 | ) 32 | .frame(maxWidth: .infinity, alignment: .leading) 33 | .padding(.vertical, 6.0) 34 | .contextMenu { 35 | if let url = post.url { 36 | Button("Open Link") { 37 | openURL(url) 38 | } 39 | } 40 | } 41 | } 42 | .listStyle(PlainListStyle()) 43 | .onChange(of: accountStore.accounts, initial: true, { _, newValue in 44 | model.updateAccounts(newValue) 45 | }) 46 | #if os(macOS) 47 | .focusedSceneValue(\.refreshAction) { 48 | Task { 49 | await model.refresh() 50 | } 51 | } 52 | #else 53 | .refreshable { 54 | await model.refresh() 55 | } 56 | #endif 57 | .task(id: model.accountsIdentifier) { 58 | await model.refresh() 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Modules/Timeline/FeedViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import CompositeSocialService 4 | import Storage 5 | import StableView 6 | 7 | @MainActor 8 | @Observable 9 | final class FeedViewModel { 10 | @ObservationIgnored 11 | private var client: CompositeClient 12 | @ObservationIgnored 13 | private var services: [any SocialService] = [] 14 | // this is needed to workaround a bug in Xcode 16.3, but my assumption is it will be resolved shortly. 15 | #if targetEnvironment(simulator) 16 | @ObservationIgnored 17 | private let responseProvider = URLSession(configuration: .ephemeral).responseProvider 18 | #else 19 | @ObservationIgnored 20 | private let responseProvider = URLSession.defaultProvider 21 | #endif 22 | @ObservationIgnored 23 | private let secretStore: SecretStore 24 | @ObservationIgnored 25 | private let timelineStore: TimelineStore 26 | 27 | private(set) var accountsIdentifier: Int 28 | var positionAnchor: AnchoredListPosition? { 29 | didSet { 30 | if let pos = positionAnchor { 31 | // let handle = (pos.item.repostingAuthor ?? pos.item.author)?.handle 32 | // 33 | // print("position:", handle, pos.offset) 34 | updateServicePosition(for: pos.item) 35 | } 36 | } 37 | } 38 | 39 | private(set) var posts: [Post] = [] 40 | 41 | init(secretStore: SecretStore, timelineStore: TimelineStore) { 42 | self.secretStore = secretStore 43 | self.timelineStore = timelineStore 44 | self.client = CompositeClient( 45 | secretStore: secretStore, 46 | services: [] 47 | ) 48 | 49 | self.accountsIdentifier = 0 50 | } 51 | 52 | var servicePosition: ServicePosition? { 53 | timelineStore.position 54 | } 55 | 56 | private func updateServicePosition(for post: Post) { 57 | var pos = servicePosition ?? .unknown 58 | 59 | pos.date = post.date 60 | 61 | if let bskyCursor = post.blueskyCursor { 62 | pos.bluesky = bskyCursor 63 | } 64 | 65 | if let statusId = post.mastodonStatusId { 66 | pos.mastodon = statusId 67 | } 68 | 69 | timelineStore.position = pos 70 | } 71 | 72 | var aboveCount: Int { 73 | guard 74 | let pos = positionAnchor, 75 | let idx = posts.firstIndex(of: pos.item) 76 | else { 77 | return -1 78 | } 79 | 80 | return idx 81 | } 82 | 83 | func refresh() async { 84 | if client.services.isEmpty { 85 | return 86 | } 87 | 88 | let position = servicePosition ?? .unknown 89 | print("refreshing from:", position) 90 | 91 | do { 92 | let newPosts = try await client.timeline(from: position, newer: true) 93 | 94 | mergeNewPosts(newPosts) 95 | } catch { 96 | print("dammm", error) 97 | } 98 | } 99 | 100 | private func mergeNewPosts(_ newPosts: [Post]) { 101 | var currentPosts = posts 102 | 103 | // filter out duplicates 104 | let currentIds = Set(currentPosts.map { $0.id }) 105 | let newPosts = newPosts.filter({ currentIds.contains($0.id) == false }) 106 | 107 | let currentCount = currentPosts.count 108 | let removeCount = (currentCount + newPosts.count) - timelineStore.maximumPosts 109 | 110 | 111 | if removeCount > 0 { 112 | currentPosts.removeLast(min(removeCount, currentCount)) 113 | } 114 | 115 | currentPosts.append(contentsOf: newPosts) 116 | currentPosts.sort(by: { $0 > $1 }) 117 | 118 | self.posts = currentPosts 119 | } 120 | 121 | func updateAccounts(_ accounts: [UserAccount]) { 122 | let services = accounts 123 | .map { (account) -> any SocialService in 124 | switch account.source { 125 | case .mastodon: 126 | MastodonService( 127 | with: responseProvider, 128 | host: account.details.host, 129 | secretStore: secretStore 130 | ) 131 | case .bluesky: 132 | BlueskyService( 133 | with: responseProvider, 134 | authServer: account.details.host, 135 | account: account.details.user, 136 | secretStore: secretStore 137 | ) 138 | } 139 | } 140 | 141 | self.client = CompositeClient(secretStore: secretStore, services: services) 142 | self.accountsIdentifier = accounts.hashValue 143 | } 144 | 145 | func handlePostAction(action: PostStatusAction, post: Post) { 146 | switch action { 147 | case .like: 148 | Task { 149 | try! await self.client.likePost(post) 150 | } 151 | case .repost: 152 | print("nope, not yet") 153 | } 154 | 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Modules/Timeline/LoadedImageView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct LoadedImageView: View { 4 | let url: URL? 5 | let placeholderName: String 6 | 7 | init(url: URL?, placeholderName: String = "person.fill") { 8 | self.url = url 9 | self.placeholderName = placeholderName 10 | } 11 | 12 | var body: some View { 13 | AsyncImage(url: url) { image in 14 | image 15 | .resizable() 16 | .aspectRatio(contentMode: .fit) 17 | } placeholder: { 18 | Image(systemName: placeholderName) 19 | } 20 | } 21 | } 22 | 23 | #Preview { 24 | LoadedImageView(url: URL(string: "https://robohash.org/abc.png")!) 25 | } 26 | -------------------------------------------------------------------------------- /Modules/Timeline/PostAttachmentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import CompositeSocialService 4 | 5 | struct PostAttachmentView: View { 6 | let attachments: [Attachment] 7 | 8 | var body: some View { 9 | HStack { 10 | ForEach(attachments, id: \.hashValue) { attachment in 11 | switch attachment { 12 | case let .images(collection): 13 | HStack { 14 | ForEach(collection, id: \.hashValue) { imageDetails in 15 | AttachmentImageView(url: imageDetails.preview?.url) 16 | } 17 | } 18 | case let .link(link): 19 | VStack { 20 | AttachmentImageView(url: link.preview?.url) 21 | Text(link.title ?? "no title") 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Modules/Timeline/PostContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import CompositeSocialService 4 | 5 | struct PostContentView: View { 6 | let post: Post 7 | 8 | var body: some View { 9 | VStack(alignment: .leading) { 10 | Text(attributedContent) 11 | .fixedSize(horizontal: false, vertical: true) 12 | .padding(insets) 13 | PostAttachmentView(attachments: post.attachments) 14 | } 15 | } 16 | 17 | var insets: EdgeInsets { 18 | EdgeInsets(top: 4.0, leading: 2.0, bottom: 4.0, trailing: 1.0) 19 | } 20 | 21 | var attributedContent: AttributedString { 22 | AttributedString(post.content ?? "") 23 | } 24 | } 25 | 26 | #Preview { 27 | PostContentView( 28 | post: Post( 29 | content: "hello", 30 | source: .mastodon, 31 | date: .now, 32 | author: Author(name: "author", handle: "me@me"), 33 | repostingAuthor: nil, 34 | identifier: "1234", 35 | url: nil, 36 | attachments: [], 37 | status: PostStatus(likeCount: 0, liked: false, repostCount: 0, reposted: false) 38 | ) 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /Modules/Timeline/PostStatusView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import CompositeSocialService 4 | import Storage 5 | 6 | @MainActor 7 | struct LikeAction { 8 | public func callAsFunction() { 9 | 10 | } 11 | } 12 | 13 | public enum PostStatusAction { 14 | case like 15 | case repost 16 | } 17 | 18 | public struct PostStatusView: View { 19 | public typealias ActionHandler = (PostStatusAction) -> Void 20 | 21 | public let source: DataSource 22 | public let status: PostStatus 23 | public let actionHandler: ActionHandler 24 | 25 | var likeImageName: String { 26 | status.liked ? "heart.fill" : "heart" 27 | } 28 | 29 | var repostImageName: String { 30 | "arrow.2.squarepath" 31 | } 32 | 33 | public var body: some View { 34 | HStack { 35 | Image(source.imageName) 36 | Image(systemName: likeImageName) 37 | .onTapGesture { 38 | actionHandler(.like) 39 | } 40 | Text("\(status.likeCount)") 41 | Image(systemName: repostImageName) 42 | .onTapGesture { 43 | actionHandler(.repost) 44 | } 45 | Text("\(status.repostCount)") 46 | } 47 | } 48 | } 49 | 50 | #Preview { 51 | PostStatusView( 52 | source: .mastodon, 53 | status: PostStatus(likeCount: 0, liked: false, repostCount: 0, reposted: false), 54 | actionHandler: { _ in } 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /Modules/Timeline/PostView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import CompositeSocialService 4 | 5 | struct PostView: View { 6 | let post: Post 7 | let actionHandler: PostStatusView.ActionHandler 8 | @State private var formatter: RelativeDateTimeFormatter = { 9 | let formatter = RelativeDateTimeFormatter() 10 | 11 | formatter.dateTimeStyle = .named 12 | formatter.unitsStyle = .abbreviated 13 | 14 | return formatter 15 | }() 16 | 17 | var body: some View { 18 | HStack(alignment: .top) { 19 | AvatarView(url: post.author.avatarURL) 20 | VStack(alignment: .leading) { 21 | HStack { 22 | Text(post.repostingAuthor?.handle ?? post.author.handle) 23 | .font(.caption) 24 | Text(formatter.localizedString(for: post.date, relativeTo: .now)) 25 | } 26 | PostContentView(post: post) 27 | PostStatusView( 28 | source: post.source, 29 | status: post.status, 30 | actionHandler: actionHandler 31 | ) 32 | .padding(EdgeInsets(top: 2.0, leading: 0.0, bottom: 0.0, trailing: 0.0)) 33 | } 34 | .padding(EdgeInsets(top: 0.0, leading: 4.0, bottom: 0.0, trailing: 0.0)) 35 | } 36 | } 37 | } 38 | 39 | #Preview { 40 | PostView(post: Post.placeholder, actionHandler: { _ in }) 41 | } 42 | -------------------------------------------------------------------------------- /Modules/UIUtility/MenuActions.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension FocusedValues { 4 | public typealias Action = () -> Void 5 | 6 | @Entry public var refreshAction: Action? 7 | } 8 | -------------------------------------------------------------------------------- /Modules/UIUtility/Platform.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | #if os(macOS) 4 | public struct TextInputAutocapitalization: Sendable { 5 | public static let never = TextInputAutocapitalization() 6 | public static let words = TextInputAutocapitalization() 7 | public static let sentences = TextInputAutocapitalization() 8 | public static let characters = TextInputAutocapitalization() 9 | } 10 | #endif 11 | 12 | extension View { 13 | public nonisolated func platform_textInputAutocapitalization(_ autocapitalization: TextInputAutocapitalization?) -> some View { 14 | #if os(macOS) 15 | self 16 | #else 17 | textInputAutocapitalization(autocapitalization) 18 | #endif 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Modules/Utility/Constants.m: -------------------------------------------------------------------------------- 1 | @import Foundation; 2 | 3 | #include 4 | 5 | #ifdef APP_GROUP 6 | NSString* const MTPAppGroupIdentifier = @OS_STRINGIFY(APP_GROUP); 7 | #else 8 | #error Undefined 9 | #endif 10 | 11 | #ifdef APP_IDENTIFIER_PREFIX 12 | NSString* const MTPAppIdentifierPrefix = @OS_STRINGIFY(APP_IDENTIFIER_PREFIX); 13 | #else 14 | #error Undefined 15 | #endif 16 | -------------------------------------------------------------------------------- /Modules/Utility/UserDefaults+Shared.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension UserDefaults { 4 | public static var sharedSuite: UserDefaults? { 5 | UserDefaults(suiteName: MTPAppGroupIdentifier) 6 | } 7 | } 8 | 9 | extension FileManager { 10 | public var appGroupURL: URL? { 11 | containerURL(forSecurityApplicationGroupIdentifier: MTPAppGroupIdentifier) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Modules/Utility/Utility-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | @import Foundation; 6 | 7 | extern NSString* const MTPAppGroupIdentifier; 8 | extern NSString* const MTPAppIdentifierPrefix; 9 | -------------------------------------------------------------------------------- /Multipass.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Multipass.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "da0e5ba9af648a46b69074f1b4d6471fe5fa47f726c93b4ee1b4a9c3cfe114cb", 3 | "pins" : [ 4 | { 5 | "identity" : "atat", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/mattmassicotte/ATAT", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "7bcb5858b756d5507d0bcbb3b6359ef7a21daa74" 11 | } 12 | }, 13 | { 14 | "identity" : "atresolve", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/mattmassicotte/ATResolve", 17 | "state" : { 18 | "branch" : "main", 19 | "revision" : "6d86cb8b2bfcdb4ed2560fd3c638c232d87cc7f4" 20 | } 21 | }, 22 | { 23 | "identity" : "empire", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/mattmassicotte/Empire", 26 | "state" : { 27 | "branch" : "main", 28 | "revision" : "fe65f335e4afab72172ed8c3c5436a55f54667ac" 29 | } 30 | }, 31 | { 32 | "identity" : "jot", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/mattmassicotte/Jot", 35 | "state" : { 36 | "branch" : "main", 37 | "revision" : "0826782b0489dc76249b5def7d69f38684e5cb7f" 38 | } 39 | }, 40 | { 41 | "identity" : "oauthenticator", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/ChimeHQ/OAuthenticator.git", 44 | "state" : { 45 | "branch" : "main", 46 | "revision" : "ffe2854423475976b2b9c404a104180a678eb48e" 47 | } 48 | }, 49 | { 50 | "identity" : "reblog", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/mattmassicotte/Reblog", 53 | "state" : { 54 | "branch" : "main", 55 | "revision" : "2840bc217873068b4c9da891dc6ceec122ad6e37" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-async-dns-resolver", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/apple/swift-async-dns-resolver.git", 62 | "state" : { 63 | "revision" : "08c07ff31a745ee5e522ac10132fb4949834d925", 64 | "version" : "0.4.0" 65 | } 66 | }, 67 | { 68 | "identity" : "swift-syntax", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/swiftlang/swift-syntax.git", 71 | "state" : { 72 | "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", 73 | "version" : "601.0.1" 74 | } 75 | }, 76 | { 77 | "identity" : "valet", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/square/Valet", 80 | "state" : { 81 | "revision" : "05c9e514fbd352a6866877ca31326b4e0b7d6d01", 82 | "version" : "5.0.0" 83 | } 84 | } 85 | ], 86 | "version" : 3 87 | } 88 | -------------------------------------------------------------------------------- /Multipass.xcodeproj/xcshareddata/xcschemes/CompositeSocialService.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Multipass.xcodeproj/xcshareddata/xcschemes/Multipass.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 36 | 42 | 43 | 44 | 47 | 53 | 54 | 55 | 56 | 57 | 67 | 69 | 75 | 76 | 77 | 78 | 84 | 86 | 92 | 93 | 94 | 95 | 97 | 98 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /Multipass/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 | -------------------------------------------------------------------------------- /Multipass/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | }, 30 | { 31 | "idiom" : "mac", 32 | "scale" : "1x", 33 | "size" : "16x16" 34 | }, 35 | { 36 | "idiom" : "mac", 37 | "scale" : "2x", 38 | "size" : "16x16" 39 | }, 40 | { 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "32x32" 44 | }, 45 | { 46 | "idiom" : "mac", 47 | "scale" : "2x", 48 | "size" : "32x32" 49 | }, 50 | { 51 | "idiom" : "mac", 52 | "scale" : "1x", 53 | "size" : "128x128" 54 | }, 55 | { 56 | "idiom" : "mac", 57 | "scale" : "2x", 58 | "size" : "128x128" 59 | }, 60 | { 61 | "idiom" : "mac", 62 | "scale" : "1x", 63 | "size" : "256x256" 64 | }, 65 | { 66 | "idiom" : "mac", 67 | "scale" : "2x", 68 | "size" : "256x256" 69 | }, 70 | { 71 | "idiom" : "mac", 72 | "scale" : "1x", 73 | "size" : "512x512" 74 | }, 75 | { 76 | "idiom" : "mac", 77 | "scale" : "2x", 78 | "size" : "512x512" 79 | } 80 | ], 81 | "info" : { 82 | "author" : "xcode", 83 | "version" : 1 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Multipass/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Multipass/Assets.xcassets/bluesky.symbolset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "symbols" : [ 7 | { 8 | "filename" : "bluesky.svg", 9 | "idiom" : "universal" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /Multipass/Assets.xcassets/bluesky.symbolset/bluesky.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 16 | 17 | 18 | 19 | Weight/Scale Variations 20 | Ultralight 21 | Thin 22 | Light 23 | Regular 24 | Medium 25 | Semibold 26 | Bold 27 | Heavy 28 | Black 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | Design Variations 40 | Symbols are supported in up to nine weights and three scales. 41 | For optimal layout with text and other symbols, vertically align 42 | symbols with the adjacent text. 43 | 44 | 45 | 46 | 47 | 48 | Margins 49 | Leading and trailing margins on the left and right side of each symbol 50 | can be adjusted by modifying the x-location of the margin guidelines. 51 | Modifications are automatically applied proportionally to all 52 | scales and weights. 53 | 54 | 55 | 56 | Exporting 57 | Symbols should be outlined when exporting to ensure the 58 | design is preserved when submitting to Xcode. 59 | Template v.5.0 60 | Requires Xcode 15 or greater 61 | Generated from bluesky 62 | Typeset at 100.0 points 63 | Small 64 | Medium 65 | Large 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /Multipass/Assets.xcassets/mastodon.clean.fill.symbolset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "symbols" : [ 7 | { 8 | "filename" : "mastodon.clean.fill.svg", 9 | "idiom" : "universal" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /Multipass/Assets.xcassets/mastodon.clean.fill.symbolset/mastodon.clean.fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 20 | 21 | 22 | 23 | Weight/Scale Variations 24 | Ultralight 25 | Thin 26 | Light 27 | Regular 28 | Medium 29 | Semibold 30 | Bold 31 | Heavy 32 | Black 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | Design Variations 44 | Symbols are supported in up to nine weights and three scales. 45 | For optimal layout with text and other symbols, vertically align 46 | symbols with the adjacent text. 47 | 48 | 49 | 50 | 51 | 52 | Margins 53 | Leading and trailing margins on the left and right side of each symbol 54 | can be adjusted by modifying the x-location of the margin guidelines. 55 | Modifications are automatically applied proportionally to all 56 | scales and weights. 57 | 58 | 59 | 60 | Exporting 61 | Symbols should be outlined when exporting to ensure the 62 | design is preserved when submitting to Xcode. 63 | Template v.4.0 64 | Requires Xcode 14 or greater 65 | Generated from mastodon.clean.fill 66 | Typeset at 100 points 67 | Small 68 | Medium 69 | Large 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | -------------------------------------------------------------------------------- /Multipass/MainAppView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import Settings 4 | import Timeline 5 | 6 | struct MainAppView: View { 7 | let appState: AppState 8 | @State private var settingsVisible = false 9 | 10 | #if os(macOS) 11 | var body: some View { 12 | VStack { 13 | FeedView( 14 | secretStore: appState.secretStore, 15 | timelineStore: appState.timelineStore 16 | ) 17 | } 18 | .padding() 19 | } 20 | #else 21 | var body: some View { 22 | NavigationStack { 23 | VStack { 24 | FeedView( 25 | secretStore: appState.secretStore, 26 | timelineStore: appState.timelineStore 27 | ) 28 | } 29 | .toolbar { 30 | Button { 31 | settingsVisible = true 32 | } label: { 33 | Image(systemName: "gear") 34 | } 35 | 36 | } 37 | .environment(appState.accountStore) 38 | } 39 | .sheet(isPresented: $settingsVisible) { 40 | SettingsView() 41 | .environment(appState.accountStore) 42 | } 43 | 44 | } 45 | #endif 46 | } 47 | -------------------------------------------------------------------------------- /Multipass/MenuCommands.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import UIUtility 4 | 5 | struct MenuCommands: Commands { 6 | @FocusedValue(\.refreshAction) var refreshAction 7 | 8 | var body: some Commands { 9 | CommandGroup(after: .pasteboard) { 10 | Divider() 11 | Button("Refresh") { 12 | refreshAction?() 13 | } 14 | .keyboardShortcut("r") 15 | .disabled(refreshAction == nil) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Multipass/Multipass.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | $(APP_GROUP) 10 | 11 | com.apple.security.files.user-selected.read-only 12 | 13 | com.apple.security.network.client 14 | 15 | keychain-access-groups 16 | 17 | $(KEYCHAIN_ACCESS_GROUP) 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Multipass/MultipassApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import CompositeSocialService 4 | import Storage 5 | import Settings 6 | import UIUtility 7 | import Valet 8 | 9 | @MainActor 10 | final class AppState { 11 | let secretStore = SecretStore.valetStore(using: Valet.mainApp()) 12 | let accountStore: UserAccountStore 13 | let timelineStore: TimelineStore 14 | 15 | init() { 16 | self.accountStore = UserAccountStore(secretStore: secretStore) 17 | self.timelineStore = TimelineStore() 18 | } 19 | } 20 | 21 | @main 22 | struct MultipassApp: App { 23 | @State private var appState = AppState() 24 | 25 | var body: some Scene { 26 | WindowGroup { 27 | MainAppView(appState: appState) 28 | .environment(appState.accountStore) 29 | .environment(appState.timelineStore) 30 | } 31 | .commands { 32 | MenuCommands() 33 | } 34 | 35 | #if os(macOS) 36 | Settings { 37 | SettingsView() 38 | } 39 | .environment(appState.accountStore) 40 | #endif 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Multipass/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Multipass/Valet+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | import CompositeSocialService 4 | import Storage 5 | import Utility 6 | import Valet 7 | 8 | extension Valet { 9 | static func mainApp() -> Valet { 10 | let bundleId = Bundle.main.bundleIdentifier! 11 | let groupId = SharedGroupIdentifier(appIDPrefix: MTPAppIdentifierPrefix, nonEmptyGroup: bundleId)! 12 | 13 | return Valet.sharedGroupValet(with: groupId, accessibility: .whenUnlocked) 14 | } 15 | } 16 | 17 | extension SecretStore { 18 | static func valetStore(using valet: Valet) -> SecretStore { 19 | SecretStore( 20 | read: { 21 | do { 22 | return try valet.object(forKey: $0) 23 | } catch KeychainError.itemNotFound { 24 | return nil 25 | } 26 | }, 27 | write: { 28 | try valet.setObject($0, forKey: $1) 29 | } 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /MultipassTests/MastodonAPITests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import Foundation 3 | 4 | import MastodonAPI 5 | import Reblog 6 | 7 | struct MastodonAPITests { 8 | @Test func markerDecode() async throws { 9 | let data = """ 10 | {"home":{"last_read_id":"112327167530043732","version":425,"updated_at":"2024-04-24T16:38:07.000Z"},"notifications":{"last_read_id":"339514663","version":12806,"updated_at":"2024-11-15T18:16:35.000Z"}} 11 | """ 12 | 13 | let client = MastodonAPI.Client(host: "abc", provider: { _ in 14 | return (Data(data.utf8), URLResponse()) 15 | }) 16 | 17 | let markers = try await client.markers() 18 | 19 | let expected = MarkerResponse( 20 | home: Marker(lastReadId: "112327167530043732", version: 425, updatedAt: Date(timeIntervalSince1970: 1713976687)), 21 | notifications: Marker(lastReadId: "339514663", version: 12806, updatedAt: Date(timeIntervalSince1970: 1731694595)) 22 | ) 23 | #expect(markers == expected) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /MultipassUITests/MultipassUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultipassUITests.swift 3 | // MultipassUITests 4 | // 5 | // Created by Matthew Massicotte on 2024-11-13. 6 | // 7 | 8 | import XCTest 9 | 10 | final class MultipassUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | @MainActor 26 | func testExample() throws { 27 | // UI tests must launch the application that they test. 28 | let app = XCUIApplication() 29 | app.launch() 30 | 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | @MainActor 35 | func testLaunchPerformance() throws { 36 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 37 | // This measures how long it takes to launch your application. 38 | measure(metrics: [XCTApplicationLaunchMetric()]) { 39 | XCUIApplication().launch() 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /MultipassUITests/MultipassUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultipassUITestsLaunchTests.swift 3 | // MultipassUITests 4 | // 5 | // Created by Matthew Massicotte on 2024-11-13. 6 | // 7 | 8 | import XCTest 9 | 10 | final class MultipassUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | @MainActor 21 | func testLaunch() throws { 22 | let app = XCUIApplication() 23 | app.launch() 24 | 25 | // Insert steps here to perform after app launch but before taking a screenshot, 26 | // such as logging into a test account or navigating somewhere in the app 27 | 28 | let attachment = XCTAttachment(screenshot: app.screenshot()) 29 | attachment.name = "Launch Screen" 30 | attachment.lifetime = .keepAlways 31 | add(attachment) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Multipass 2 | Yes she knows it's a multipass 3 | 4 | Multipass can merge Bluesky and Mastodon feeds into a unified timeline. Supports macOS and iOS. 5 | 6 | > [!WARNING] 7 | > This app is juuust barely functional. I just cannot stress enough what a poor state this is currently in. 8 | 9 |

10 | 11 |

12 |

13 | 14 |

15 | 16 | ## Usage 17 | 18 | You can add and remove accounts and they will be persisted in the Keychain. You do this from settings. 19 | 20 | ### Building 21 | 22 | **Note**: requires Xcode 16 23 | 24 | - clone the repo 25 | - `cp User.xcconfig.template User.xcconfig` 26 | - update `User.xcconfig` with your personal information 27 | - build/run with Xcode 28 | 29 | ### Sub-Projects 30 | 31 | A number of subprojects have either been pulled out of this app or influenced by it. They might be of interest as well. 32 | 33 | - [ATAT](https://github.com/mattmassicotte/ATAT): Little library for working with the AT Protocol 34 | - [ATResolve](https://github.com/mattmassicotte/ATResolve): AT Protocol PLC Resolver 35 | - [Jot](https://github.com/mattmassicotte/Jot): Very simple JWT/JWK library for Swift 36 | - [OAuthenticator](https://github.com/ChimeHQ/OAuthenticator): OAuth 2.0 request authentication 37 | - [StableView](https://github.com/mattmassicotte/StableView): A TableView implementation that can preserve position for iOS and macOS 38 | - [Reblog](https://github.com/mattmassicotte/Reblog): Little library for working with the Mastodon API 39 | 40 | ## Acknowledgements 41 | 42 | This project uses symbols from [social-symbols](https://github.com/jeremieb/social-symbols). It rocks. 43 | 44 | ## Contributing and Collaboration 45 | 46 | I would love to hear from you! Issues or pull requests work great. Both a [Matrix space][matrix] and [Discord][discord] are available for live help, but I have a strong bias towards answering in the form of documentation. You can also find me [here](https://www.massicotte.org/about). 47 | 48 | I prefer collaboration, and would love to find ways to work together if you have a similar project. 49 | 50 | I prefer indentation with tabs for improved accessibility. But, I'd rather you use the system you want and make a PR than hesitate because of whitespace. 51 | 52 | By participating in this project you agree to abide by the [Contributor Code of Conduct](CODE_OF_CONDUCT.md). 53 | 54 | [matrix]: https://matrix.to/#/%23chimehq%3Amatrix.org 55 | [matrix badge]: https://img.shields.io/matrix/chimehq%3Amatrix.org?label=Matrix 56 | [discord]: https://discord.gg/esFpX6sErJ 57 | -------------------------------------------------------------------------------- /User.xcconfig.template: -------------------------------------------------------------------------------- 1 | DEVELOPMENT_TEAM = YOUR_TEAM_ID 2 | BUNDLE_ID_PREFIX = com.yourcompany 3 | -------------------------------------------------------------------------------- /assets/timeline-ios.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattmassicotte/Multipass/babe6bcd86edc2c4793b6d3f1a4b42c4d9d08fcc/assets/timeline-ios.png -------------------------------------------------------------------------------- /assets/timeline-macos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattmassicotte/Multipass/babe6bcd86edc2c4793b6d3f1a4b42c4d9d08fcc/assets/timeline-macos.png -------------------------------------------------------------------------------- /client-metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "client_id": "https://downloads.chimehq.com/com.chimehq.Multipass/client-metadata.json", 3 | "application_type": "native", 4 | "grant_types": [ 5 | "authorization_code", 6 | "refresh_token" 7 | ], 8 | "scope": "atproto transition:generic", 9 | "response_types": [ 10 | "code" 11 | ], 12 | "redirect_uris": [ 13 | "com.chimehq.downloads:/atproto/callback" 14 | ], 15 | "token_endpoint_auth_method": "none", 16 | "dpop_bound_access_tokens": true, 17 | "client_name": "Multipass" 18 | } 19 | --------------------------------------------------------------------------------