├── README.md ├── Comingle ├── Assets │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Comingle_Icon_16.png │ │ │ ├── Comingle_Icon_32.png │ │ │ ├── Comingle_Icon_64.png │ │ │ ├── Comingle_Icon_1024.png │ │ │ ├── Comingle_Icon_128.png │ │ │ ├── Comingle_Icon_256 1.png │ │ │ ├── Comingle_Icon_256.png │ │ │ ├── Comingle_Icon_32 1.png │ │ │ ├── Comingle_Icon_512 1.png │ │ │ ├── Comingle_Icon_512.png │ │ │ ├── Comingle_Icon_1024 1.png │ │ │ └── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── ComingleLogo.imageset │ │ │ ├── Contents.json │ │ │ ├── ComingleLogoDark 1.svg │ │ │ ├── ComingleLogoDark 2.svg │ │ │ ├── ComingleLogoDark.svg │ │ │ ├── ComingleLogoLight 1.svg │ │ │ ├── ComingleLogoLight 2.svg │ │ │ └── ComingleLogoLight.svg │ └── Localization │ │ └── Localizable.xcstrings ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Info.plist ├── Views │ ├── Settings │ │ ├── RelayView.swift │ │ ├── AcknowledgementsView.swift │ │ ├── AppearanceSettingsView.swift │ │ ├── KeysSettingsView.swift │ │ ├── RelaysSettingsView.swift │ │ └── SettingsView.swift │ ├── ProfileNameView.swift │ ├── GuestProfilePictureView.swift │ ├── CondensedProfilePicturesView.swift │ ├── ProfilePictureAndNameView.swift │ ├── ImageOverlayView.swift │ ├── ProfilePictureView.swift │ ├── TimeZoneSelectionView.swift │ ├── CalendarsView.swift │ ├── ProfileView.swift │ ├── ParticipantSearchView.swift │ ├── CalendarListEventView.swift │ ├── LocationSearchView.swift │ ├── ContentView.swift │ ├── SignInView.swift │ ├── CreateOrModifyCalendarView.swift │ ├── CreateProfileView.swift │ └── CreateOrModifyEventView.swift ├── Models │ ├── Settings │ │ ├── AppSettings.swift │ │ ├── AppearanceSettings.swift │ │ ├── RelayPoolSettings.swift │ │ ├── RelaySettings.swift │ │ ├── ProfileSettings.swift │ │ └── TimeZonePreference.swift │ ├── SearchViewModel.swift │ ├── RelaySubscriptionMetadata.swift │ ├── PersistentNostrEvent.swift │ ├── TimeZone+Extensions.swift │ ├── EventCreationParticipantSortComparator.swift │ ├── MetadataEvent+Extensions.swift │ ├── Profile.swift │ ├── TimeBasedCalendarEvent+Extensions.swift │ ├── MKAutocompleteManager.swift │ ├── NostrEventValueTransformer.swift │ ├── TimeZoneSortComparator.swift │ ├── CalendarEventParticipantSortComparator.swift │ ├── TimeBasedCalendarEventSortComparator.swift │ ├── CalendarListEventSortComparator.swift │ ├── PublicKeySortComparator.swift │ └── RSVPSortComparator.swift ├── Comingle.entitlements ├── Utilities │ ├── String+Extensions.swift │ ├── Utilities.swift │ ├── CredentialHandler.swift │ └── PrivateKeySecureStorage.swift ├── ComingleApp.swift └── Launch Screen.storyboard ├── .swiftlint.yml ├── Comingle.xcodeproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── swiftpm │ └── Package.resolved ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── ComingleUITests ├── ComingleUITestsLaunchTests.swift └── ComingleUITests.swift ├── ComingleTests └── ComingleTests.swift ├── .gitignore └── CHANGELOG.md /README.md: -------------------------------------------------------------------------------- 1 | # Comingle 2 | 3 | Comingle is an events app powered by Nostr. 4 | 5 | © 2024 Comingle Labs LLC -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Comingle/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - cyclomatic_complexity 3 | - file_length 4 | - function_body_length 5 | - line_length 6 | - type_body_length 7 | -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comingle-co/comingle-ios/HEAD/Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_16.png -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comingle-co/comingle-ios/HEAD/Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_32.png -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comingle-co/comingle-ios/HEAD/Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_64.png -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comingle-co/comingle-ios/HEAD/Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_1024.png -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comingle-co/comingle-ios/HEAD/Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_128.png -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_256 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comingle-co/comingle-ios/HEAD/Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_256 1.png -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comingle-co/comingle-ios/HEAD/Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_256.png -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_32 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comingle-co/comingle-ios/HEAD/Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_32 1.png -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_512 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comingle-co/comingle-ios/HEAD/Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_512 1.png -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comingle-co/comingle-ios/HEAD/Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_512.png -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_1024 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comingle-co/comingle-ios/HEAD/Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Comingle_Icon_1024 1.png -------------------------------------------------------------------------------- /Comingle.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Comingle.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Comingle/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LSApplicationQueriesSchemes 6 | 7 | https 8 | nostr 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Comingle/Views/Settings/RelayView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RelayView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 8/8/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RelayView: View { 11 | var body: some View { 12 | Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) 13 | } 14 | } 15 | 16 | #Preview { 17 | RelayView() 18 | } 19 | -------------------------------------------------------------------------------- /Comingle/Models/Settings/AppSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppSettings.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/6/24. 6 | // 7 | 8 | import SwiftData 9 | 10 | @Model 11 | final class AppSettings { 12 | 13 | var activeProfile: Profile? 14 | 15 | init(activeProfile: Profile = Profile()) { 16 | self.activeProfile = activeProfile 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Comingle/Models/Settings/AppearanceSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppearanceSettings.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/6/24. 6 | // 7 | 8 | import SwiftData 9 | 10 | @Model 11 | final class AppearanceSettings { 12 | 13 | @Attribute(.unique) var publicKeyHex: String? 14 | 15 | var timeZonePreference: TimeZonePreference = TimeZonePreference.event 16 | 17 | init(publicKeyHex: String?) { 18 | self.publicKeyHex = publicKeyHex 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Comingle/Models/Settings/RelayPoolSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RelaySettings.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/11/24. 6 | // 7 | 8 | import SwiftData 9 | 10 | @Model 11 | final class RelayPoolSettings { 12 | 13 | @Attribute(.unique) var publicKeyHex: String? 14 | 15 | var relaySettingsList: [RelaySettings] 16 | 17 | init(publicKeyHex: String?) { 18 | self.publicKeyHex = publicKeyHex 19 | self.relaySettingsList = [] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Comingle/Views/ProfileNameView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileNameView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/9/24. 6 | // 7 | 8 | import NostrSDK 9 | import SwiftUI 10 | 11 | struct ProfileNameView: View { 12 | var publicKeyHex: String? 13 | 14 | @EnvironmentObject var appState: AppState 15 | 16 | var body: some View { 17 | Text(Utilities.shared.profileName(publicKeyHex: publicKeyHex, appState: appState)) 18 | } 19 | } 20 | 21 | #Preview { 22 | ProfileNameView() 23 | } 24 | -------------------------------------------------------------------------------- /Comingle/Models/Settings/RelaySettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RelaySettings.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/14/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | @Model 12 | final class RelaySettings { 13 | 14 | var relayURLString: String 15 | var read: Bool 16 | var write: Bool 17 | 18 | init(relayURLString: String, read: Bool = true, write: Bool = true) { 19 | self.relayURLString = relayURLString 20 | self.read = read 21 | self.write = write 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Comingle/Views/GuestProfilePictureView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GuestProfilePictureView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/8/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct GuestProfilePictureView: View { 11 | var size: CGFloat = 40 12 | 13 | var body: some View { 14 | Image(systemName: "person.crop.circle") 15 | .resizable() 16 | .scaledToFit() 17 | .frame(width: size) 18 | .clipShape(.circle) 19 | } 20 | } 21 | 22 | #Preview { 23 | GuestProfilePictureView() 24 | } 25 | -------------------------------------------------------------------------------- /Comingle/Models/SearchViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchViewModel.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/30/24. 6 | // 7 | 8 | import Foundation 9 | 10 | class SearchViewModel: ObservableObject { 11 | @Published var searchText = "" 12 | @Published var debouncedSearchText = "" 13 | 14 | init() { 15 | // Debounce the search text 16 | $searchText 17 | .debounce(for: .milliseconds(300), scheduler: RunLoop.main) 18 | .removeDuplicates() 19 | .assign(to: &$debouncedSearchText) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Comingle/Comingle.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.associated-domains 6 | 7 | webcredentials:comingle.co 8 | 9 | com.apple.developer.authentication-services.autofill-credential-provider 10 | 11 | com.apple.security.app-sandbox 12 | 13 | com.apple.security.files.user-selected.read-only 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Comingle/Models/RelaySubscriptionMetadata.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RelaySubscriptionMetadata.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 8/3/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | @Model 12 | final class RelaySubscriptionMetadata { 13 | 14 | @Attribute(.unique) var publicKeyHex: String? 15 | 16 | var lastBootstrapped = [URL: Date]() 17 | var lastPulledAllTimeBasedCalendarEvents = [URL: Date]() 18 | var lastPulledEventsFromFollows = [URL: Date]() 19 | 20 | init(publicKeyHex: String? = nil) { 21 | self.publicKeyHex = publicKeyHex 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Comingle/Models/PersistentNostrEvent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersistentNostrEvent.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/23/24. 6 | // 7 | 8 | import Foundation 9 | import NostrSDK 10 | import SwiftData 11 | 12 | @Model 13 | class PersistentNostrEvent { 14 | @Attribute(.unique) var eventId: String 15 | 16 | @Attribute(.transformable(by: NostrEventValueTransformer.self)) var nostrEvent: NostrEvent 17 | 18 | var relays: [URL] = [] 19 | 20 | init(nostrEvent: NostrEvent, relays: [URL] = []) { 21 | self.eventId = nostrEvent.id 22 | self.nostrEvent = nostrEvent 23 | self.relays = relays 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Comingle/Utilities/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Extensions.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 8/3/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | func trimmingPrefix(_ prefix: String) -> String { 12 | guard self.hasPrefix(prefix) else { return self } 13 | return String(self.dropFirst(prefix.count)) 14 | } 15 | 16 | var trimmedOrNilIfEmpty: String? { 17 | let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) 18 | if trimmed.isEmpty { 19 | return nil 20 | } else { 21 | return trimmed 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /Comingle/Models/Settings/ProfileSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileSettings.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/6/24. 6 | // 7 | 8 | import SwiftData 9 | 10 | @Model 11 | final class ProfileSettings { 12 | 13 | @Attribute(.unique) var publicKeyHex: String? 14 | 15 | @Relationship(deleteRule: .cascade) var relayPoolSettings: RelayPoolSettings? 16 | @Relationship(deleteRule: .cascade) var appearanceSettings: AppearanceSettings? 17 | 18 | init(publicKeyHex: String? = nil) { 19 | self.publicKeyHex = publicKeyHex 20 | relayPoolSettings = RelayPoolSettings(publicKeyHex: publicKeyHex) 21 | appearanceSettings = AppearanceSettings(publicKeyHex: publicKeyHex) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Comingle/Models/TimeZone+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeZoneExtensions.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/29/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension TimeZone { 11 | private func gmtOffset(for date: Date) -> String { 12 | let secondsFromGMT = secondsFromGMT(for: date) 13 | let hours = secondsFromGMT / 3600 14 | let minutes = abs(secondsFromGMT % 3600 / 60) 15 | 16 | if minutes == 0 { 17 | return String(format: "GMT%+0d", hours) 18 | } 19 | 20 | return String(format: "GMT%+0d:%02d", hours, minutes) 21 | } 22 | 23 | func displayName(for date: Date) -> String { 24 | "(\(gmtOffset(for: date))) \(identifier.replacingOccurrences(of: "_", with: " "))" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Comingle/Models/EventCreationParticipantSortComparator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EventCreationParticipantSortComparator.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/30/24. 6 | // 7 | 8 | import Foundation 9 | import NostrSDK 10 | 11 | struct EventCreationParticipantSortComparator: SortComparator { 12 | var order: SortOrder 13 | let appState: AppState 14 | 15 | init(order: SortOrder, appState: AppState) { 16 | self.order = order 17 | self.appState = appState 18 | } 19 | 20 | func compare(_ lhs: EventCreationParticipant, _ rhs: EventCreationParticipant) -> ComparisonResult { 21 | let publicKeySortComparator = PublicKeySortComparator(order: order, appState: appState) 22 | return publicKeySortComparator.compare(lhs.publicKeyHex, rhs.publicKeyHex) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment (please complete the following information):** 27 | - Device: [e.g. iPhone 15 Pro] 28 | - OS: [e.g. iOS 17] 29 | - Version [e.g. 0.1.0 (1)] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /Comingle/Models/Settings/TimeZonePreference.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeZonePreference.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/6/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum TimeZonePreference: CaseIterable, Codable { 11 | /// Use the time zone on the calendar event if it exists. 12 | /// Fallback to the system time zone if it does not exist. 13 | case event 14 | 15 | /// Always use the system time zone. 16 | case system 17 | 18 | var localizedString: String { 19 | switch self { 20 | case .event: 21 | String(localized: "Event", comment: "Picker option settings for using the event time zone if it exists.") 22 | case .system: 23 | String(localized: "System", comment: "Picker option settings for using the system time zone.") 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Comingle/Models/MetadataEvent+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MetadataEvent+Extensions.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 6/29/24. 6 | // 7 | 8 | import NostrSDK 9 | 10 | extension MetadataEvent { 11 | var resolvedName: String { 12 | guard let userMetadata else { 13 | return Utilities.shared.abbreviatedPublicKey(pubkey) 14 | } 15 | 16 | if let trimmedDisplayName = userMetadata.displayName?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmedDisplayName.isEmpty { 17 | return trimmedDisplayName 18 | } 19 | 20 | if let trimmedName = userMetadata.name?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmedName.isEmpty { 21 | return trimmedName 22 | } 23 | 24 | return Utilities.shared.abbreviatedPublicKey(pubkey) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Comingle/Models/Profile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Profile.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/6/24. 6 | // 7 | 8 | import SwiftData 9 | 10 | @Model 11 | final class Profile: Hashable { 12 | 13 | @Attribute(.unique) var publicKeyHex: String? 14 | 15 | @Relationship(deleteRule: .cascade) var profileSettings: ProfileSettings? 16 | 17 | @Relationship(deleteRule: .cascade) var relaySubscriptionMetadata: RelaySubscriptionMetadata? 18 | 19 | init(publicKeyHex: String? = nil) { 20 | self.publicKeyHex = publicKeyHex 21 | self.profileSettings = ProfileSettings(publicKeyHex: publicKeyHex) 22 | self.relaySubscriptionMetadata = RelaySubscriptionMetadata(publicKeyHex: publicKeyHex) 23 | } 24 | 25 | func hash(into hasher: inout Hasher) { 26 | hasher.combine(publicKeyHex) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Comingle/Models/TimeBasedCalendarEvent+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeBasedCalendarEvent+Extensions.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/30/24. 6 | // 7 | 8 | import Foundation 9 | import NostrSDK 10 | 11 | extension TimeBasedCalendarEvent { 12 | var isUpcoming: Bool { 13 | guard let startTimestamp else { 14 | return false 15 | } 16 | 17 | guard let endTimestamp else { 18 | return startTimestamp >= Date.now 19 | } 20 | 21 | return startTimestamp >= Date.now || endTimestamp >= Date.now 22 | } 23 | 24 | var isPast: Bool { 25 | guard let startTimestamp else { 26 | return false 27 | } 28 | 29 | guard let endTimestamp else { 30 | return startTimestamp < Date.now 31 | } 32 | 33 | return endTimestamp < Date.now 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xE2", 9 | "green" : "0x36", 10 | "red" : "0x83" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x38", 27 | "green" : "0x9E", 28 | "red" : "0xF1" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ComingleUITests/ComingleUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComingleUITestsLaunchTests.swift 3 | // ComingleUITests 4 | // 5 | // Created by Terry Yiu on 5/9/23. 6 | // 7 | 8 | import XCTest 9 | 10 | final class ComingleUITestsLaunchTests: XCTestCase { 11 | 12 | override static var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | func testLaunch() throws { 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Insert steps here to perform after app launch but before taking a screenshot, 25 | // such as logging into a test account or navigating somewhere in the app 26 | 27 | let attachment = XCTAttachment(screenshot: app.screenshot()) 28 | attachment.name = "Launch Screen" 29 | attachment.lifetime = .keepAlways 30 | add(attachment) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Comingle/Models/MKAutocompleteManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MKAutocompleteManager.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/31/24. 6 | // 7 | 8 | import Foundation 9 | import MapKit 10 | 11 | class MKAutocompleteManager: NSObject, ObservableObject, MKLocalSearchCompleterDelegate { 12 | @Published var searchText = "" 13 | @Published var completions: [MKLocalSearchCompletion] = [] 14 | 15 | private var searchCompleter: MKLocalSearchCompleter 16 | 17 | override init() { 18 | self.searchCompleter = MKLocalSearchCompleter() 19 | super.init() 20 | self.searchCompleter.delegate = self 21 | } 22 | 23 | func updateSearchResults() { 24 | searchCompleter.queryFragment = searchText 25 | } 26 | 27 | func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { 28 | completions = completer.results 29 | } 30 | 31 | func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { 32 | print("Error completing search: \(error.localizedDescription)") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Comingle/Views/CondensedProfilePicturesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CondensedProfilePicturesView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 8/21/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CondensedProfilePicturesView: View { 11 | @EnvironmentObject var appState: AppState 12 | 13 | let pubkeys: [String] 14 | let maxPictures: Int 15 | 16 | init(pubkeys: [String], maxPictures: Int) { 17 | self.pubkeys = pubkeys 18 | self.maxPictures = min(maxPictures, pubkeys.count) 19 | } 20 | 21 | var body: some View { 22 | // Using ZStack to make profile pictures floating and stacked on top of each other. 23 | ZStack { 24 | ForEach((0..: View { 11 | let imageSystemName: String 12 | let overlayBackgroundColor: Color 13 | @ViewBuilder let backgroundView: () -> BackgroundView 14 | 15 | var body: some View { 16 | ZStack { 17 | backgroundView() 18 | 19 | Circle() 20 | .fill(overlayBackgroundColor) 21 | .frame(width: 16, height: 16) 22 | .offset(x: 12, y: 12) 23 | .overlay( 24 | Image(systemName: imageSystemName) 25 | .resizable() 26 | .scaledToFill() 27 | .foregroundColor(.white) 28 | .frame(maxWidth: 6, maxHeight: 6) 29 | .offset(x: 12, y: 12) 30 | ) 31 | } 32 | } 33 | } 34 | 35 | #Preview { 36 | ImageOverlayView(imageSystemName: "lock.fill", overlayBackgroundColor: .accent) { 37 | Image(systemName: "person.crop.circle") 38 | .resizable() 39 | .scaledToFit() 40 | .frame(width: 40) 41 | .clipShape(.circle) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Comingle/Views/ProfilePictureView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfilePictureView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/8/24. 6 | // 7 | 8 | import Kingfisher 9 | import SwiftUI 10 | 11 | struct ProfilePictureView: View { 12 | 13 | let publicKeyHex: String? 14 | var size: CGFloat = 40 15 | 16 | @EnvironmentObject var appState: AppState 17 | 18 | var body: some View { 19 | if let publicKeyHex, 20 | let pictureURL = appState.metadataEvents[publicKeyHex]?.userMetadata?.pictureURL ?? roboHashURL { 21 | KFImage.url(pictureURL) 22 | .resizable() 23 | .placeholder { ProgressView() } 24 | .scaledToFit() 25 | .frame(width: size) 26 | .clipShape(.circle) 27 | } else { 28 | GuestProfilePictureView(size: size) 29 | } 30 | } 31 | 32 | private var roboHashURL: URL? { 33 | guard let publicKeyHex else { 34 | return nil 35 | } 36 | 37 | return URL(string: "https://robohash.org/\(publicKeyHex)?set=set4") 38 | } 39 | } 40 | 41 | //struct ProfilePictureView_Previews: PreviewProvider { 42 | // 43 | // @State static var appState = AppState() 44 | // 45 | // static var previews: some View { 46 | // ProfilePictureView(publicKeyHex: "fake-pubkey") 47 | // } 48 | //} 49 | -------------------------------------------------------------------------------- /ComingleTests/ComingleTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComingleTests.swift 3 | // ComingleTests 4 | // 5 | // Created by Terry Yiu on 5/9/23. 6 | // 7 | 8 | import XCTest 9 | 10 | final class ComingleTests: 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 | 16 | override func tearDownWithError() throws { 17 | // Put teardown code here. This method is called after the invocation of each test method in the class. 18 | } 19 | 20 | func testExample() throws { 21 | // This is an example of a functional test case. 22 | // Use XCTAssert and related functions to verify your tests produce the correct results. 23 | // Any test you write for XCTest can be annotated as throws and async. 24 | // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. 25 | // Mark your test async to allow awaiting for asynchronous code to complete. 26 | // Check the results with assertions afterwards. 27 | } 28 | 29 | func testPerformanceExample() throws { 30 | // This is an example of a performance test case. 31 | measure { 32 | // Put the code you want to measure the time of here. 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/ComingleLogo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "ComingleLogoLight.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "filename" : "ComingleLogoDark.svg", 16 | "idiom" : "universal", 17 | "scale" : "1x" 18 | }, 19 | { 20 | "filename" : "ComingleLogoLight 1.svg", 21 | "idiom" : "universal", 22 | "scale" : "2x" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "filename" : "ComingleLogoDark 1.svg", 32 | "idiom" : "universal", 33 | "scale" : "2x" 34 | }, 35 | { 36 | "filename" : "ComingleLogoLight 2.svg", 37 | "idiom" : "universal", 38 | "scale" : "3x" 39 | }, 40 | { 41 | "appearances" : [ 42 | { 43 | "appearance" : "luminosity", 44 | "value" : "dark" 45 | } 46 | ], 47 | "filename" : "ComingleLogoDark 2.svg", 48 | "idiom" : "universal", 49 | "scale" : "3x" 50 | } 51 | ], 52 | "info" : { 53 | "author" : "xcode", 54 | "version" : 1 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /ComingleUITests/ComingleUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComingleUITests.swift 3 | // ComingleUITests 4 | // 5 | // Created by Terry Yiu on 5/9/23. 6 | // 7 | 8 | import XCTest 9 | 10 | final class ComingleUITests: 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 19 | // tests before they run. The setUp method is a good place to do this. 20 | } 21 | 22 | override func tearDownWithError() throws { 23 | // Put teardown code here. This method is called after the invocation of each test method in the class. 24 | } 25 | 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 | func testLaunchPerformance() throws { 35 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 36 | // This measures how long it takes to launch your application. 37 | measure(metrics: [XCTApplicationLaunchMetric()]) { 38 | XCUIApplication().launch() 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Comingle/Utilities/Utilities.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utilities.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/9/24. 6 | // 7 | 8 | import Foundation 9 | import NostrSDK 10 | import SwiftUI 11 | 12 | class Utilities { 13 | static let shared = Utilities() 14 | 15 | func profileName(publicKeyHex: String?, appState: AppState) -> String { 16 | if let publicKeyHex { 17 | if let resolvedName = appState.metadataEvents[publicKeyHex]?.resolvedName { 18 | return resolvedName 19 | } else { 20 | return abbreviatedPublicKey(publicKeyHex) 21 | } 22 | } else { 23 | return String(localized: "Guest", comment: "Name of Guest account that is not signed in.") 24 | } 25 | } 26 | 27 | func abbreviatedPublicKey(_ publicKeyHex: String) -> String { 28 | if let publicKey = PublicKey(hex: publicKeyHex) { 29 | return abbreviatedPublicKey(publicKey) 30 | } else { 31 | return publicKeyHex 32 | } 33 | } 34 | 35 | func abbreviatedPublicKey(_ publicKey: PublicKey) -> String { 36 | return "\(publicKey.npub.prefix(12)):\(publicKey.npub.suffix(12))" 37 | } 38 | 39 | func externalNostrProfileURL(npub: String) -> URL? { 40 | if let nostrURL = URL(string: "nostr:\(npub)"), UIApplication.shared.canOpenURL(nostrURL) { 41 | return nostrURL 42 | } 43 | if let njumpURL = URL(string: "https://njump.me/\(npub)"), UIApplication.shared.canOpenURL(njumpURL) { 44 | return njumpURL 45 | } 46 | return nil 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Comingle/Models/NostrEventValueTransformer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NostrEventValueTransformer.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/23/24. 6 | // 7 | 8 | import Foundation 9 | import NostrSDK 10 | 11 | class NostrEventValueTransformer: ValueTransformer { 12 | override class func transformedValueClass() -> AnyClass { 13 | return NostrEvent.self 14 | } 15 | 16 | override class func allowsReverseTransformation() -> Bool { 17 | return true 18 | } 19 | 20 | override func transformedValue(_ value: Any?) -> Any? { 21 | guard let nostrEvent = value as? NostrEvent else { 22 | return nil 23 | } 24 | 25 | return try? JSONEncoder().encode(nostrEvent) 26 | } 27 | 28 | override func reverseTransformedValue(_ value: Any?) -> Any? { 29 | guard let data = value as? Data else { 30 | return nil 31 | } 32 | 33 | let jsonDecoder = JSONDecoder() 34 | 35 | guard let eventKindMapper = try? jsonDecoder.decode(EventKindMapper.self, from: data) else { 36 | return nil 37 | } 38 | 39 | return try? jsonDecoder.decode(eventKindMapper.classForKind, from: data) 40 | } 41 | 42 | static func register() { 43 | ValueTransformer.setValueTransformer(NostrEventValueTransformer(), forName: .init("NostrEventValueTransformer")) 44 | } 45 | } 46 | 47 | private struct EventKindMapper: Decodable { 48 | let kind: EventKind 49 | 50 | enum CodingKeys: CodingKey { 51 | case kind 52 | } 53 | 54 | var classForKind: NostrEvent.Type { 55 | kind.classForKind 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Comingle/Models/TimeZoneSortComparator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeZoneSortComparator.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/29/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct TimeZoneSortComparator: SortComparator { 11 | var order: SortOrder 12 | var date: Date 13 | 14 | init(order: SortOrder, date: Date) { 15 | self.order = order 16 | self.date = date 17 | } 18 | 19 | func compare(_ lhs: TimeZone, _ rhs: TimeZone) -> ComparisonResult { 20 | let comparisonResult = compareForward(lhs, rhs) 21 | switch order { 22 | case .forward: 23 | return comparisonResult 24 | case .reverse: 25 | switch comparisonResult { 26 | case .orderedAscending: 27 | return .orderedDescending 28 | case .orderedDescending: 29 | return .orderedAscending 30 | case .orderedSame: 31 | return .orderedSame 32 | } 33 | } 34 | } 35 | 36 | private func compareForward(_ lhs: TimeZone, _ rhs: TimeZone) -> ComparisonResult { 37 | if lhs == rhs { 38 | return .orderedSame 39 | } 40 | 41 | let lhsSeconds = lhs.secondsFromGMT(for: date) 42 | let rhsSeconds = rhs.secondsFromGMT(for: date) 43 | 44 | if lhsSeconds == rhsSeconds { 45 | return lhs.identifier.compare(rhs.identifier) 46 | } 47 | 48 | if lhsSeconds < rhsSeconds { 49 | return .orderedAscending 50 | } else if lhsSeconds > rhsSeconds { 51 | return .orderedDescending 52 | } else { 53 | return .orderedSame 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Comingle_Icon_1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "filename" : "Comingle_Icon_16.png", 11 | "idiom" : "mac", 12 | "scale" : "1x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "Comingle_Icon_32 1.png", 17 | "idiom" : "mac", 18 | "scale" : "2x", 19 | "size" : "16x16" 20 | }, 21 | { 22 | "filename" : "Comingle_Icon_32.png", 23 | "idiom" : "mac", 24 | "scale" : "1x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "Comingle_Icon_64.png", 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "32x32" 32 | }, 33 | { 34 | "filename" : "Comingle_Icon_128.png", 35 | "idiom" : "mac", 36 | "scale" : "1x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "Comingle_Icon_256 1.png", 41 | "idiom" : "mac", 42 | "scale" : "2x", 43 | "size" : "128x128" 44 | }, 45 | { 46 | "filename" : "Comingle_Icon_256.png", 47 | "idiom" : "mac", 48 | "scale" : "1x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "Comingle_Icon_512 1.png", 53 | "idiom" : "mac", 54 | "scale" : "2x", 55 | "size" : "256x256" 56 | }, 57 | { 58 | "filename" : "Comingle_Icon_512.png", 59 | "idiom" : "mac", 60 | "scale" : "1x", 61 | "size" : "512x512" 62 | }, 63 | { 64 | "filename" : "Comingle_Icon_1024 1.png", 65 | "idiom" : "mac", 66 | "scale" : "2x", 67 | "size" : "512x512" 68 | } 69 | ], 70 | "info" : { 71 | "author" : "xcode", 72 | "version" : 1 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Comingle/Models/CalendarEventParticipantSortComparator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarEventParticipantSortComparator.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/30/24. 6 | // 7 | 8 | import Foundation 9 | import NostrSDK 10 | 11 | struct CalendarEventParticipantSortComparator: SortComparator { 12 | var order: SortOrder 13 | let appState: AppState 14 | 15 | init(order: SortOrder, appState: AppState) { 16 | self.order = order 17 | self.appState = appState 18 | } 19 | 20 | func compare(_ lhs: CalendarEventParticipant, _ rhs: CalendarEventParticipant) -> ComparisonResult { 21 | let comparisonResult = compareForward(lhs, rhs) 22 | switch order { 23 | case .forward: 24 | return comparisonResult 25 | case .reverse: 26 | switch comparisonResult { 27 | case .orderedAscending: 28 | return .orderedDescending 29 | case .orderedDescending: 30 | return .orderedAscending 31 | case .orderedSame: 32 | return .orderedSame 33 | } 34 | } 35 | } 36 | 37 | private func compareForward(_ lhs: CalendarEventParticipant, _ rhs: CalendarEventParticipant) -> ComparisonResult { 38 | switch (lhs.role, rhs.role) { 39 | case (nil, nil): 40 | break 41 | case (nil, _): 42 | return .orderedDescending 43 | case (_, nil): 44 | return .orderedAscending 45 | default: 46 | break 47 | } 48 | 49 | if let lhsRole = lhs.role, let rhsRole = rhs.role { 50 | return lhsRole.caseInsensitiveCompare(rhsRole) 51 | } 52 | 53 | guard let lhsPubkey = lhs.pubkey, let rhsPubkey = rhs.pubkey else { 54 | return .orderedSame 55 | } 56 | 57 | let publicKeySortComparator = PublicKeySortComparator(order: .forward, appState: appState) 58 | return publicKeySortComparator.compare(lhsPubkey.hex, rhsPubkey.hex) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Comingle/Models/TimeBasedCalendarEventSortComparator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeBasedCalendarEventSortComparator.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/4/24. 6 | // 7 | 8 | import Foundation 9 | import NostrSDK 10 | 11 | struct TimeBasedCalendarEventSortComparator: SortComparator { 12 | var order: SortOrder 13 | 14 | func compare(_ lhs: TimeBasedCalendarEvent, _ rhs: TimeBasedCalendarEvent) -> ComparisonResult { 15 | let comparisonResult = compareForward(lhs, rhs) 16 | switch order { 17 | case .forward: 18 | return comparisonResult 19 | case .reverse: 20 | switch comparisonResult { 21 | case .orderedAscending: 22 | return .orderedDescending 23 | case .orderedDescending: 24 | return .orderedAscending 25 | case .orderedSame: 26 | return .orderedSame 27 | } 28 | } 29 | } 30 | 31 | private func compareForward(_ lhs: TimeBasedCalendarEvent, _ rhs: TimeBasedCalendarEvent) -> ComparisonResult { 32 | if lhs == rhs { 33 | return .orderedSame 34 | } 35 | 36 | guard let lhsStartTimestamp = lhs.startTimestamp else { 37 | return .orderedDescending 38 | } 39 | 40 | guard let rhsStartTimestamp = rhs.startTimestamp else { 41 | return .orderedAscending 42 | } 43 | 44 | let lhsEndTimestamp = lhs.endTimestamp ?? lhsStartTimestamp 45 | let rhsEndTimestamp = rhs.endTimestamp ?? rhsStartTimestamp 46 | 47 | if lhsStartTimestamp < rhsStartTimestamp { 48 | return .orderedAscending 49 | } else if lhsStartTimestamp > rhsStartTimestamp { 50 | return .orderedDescending 51 | } else { 52 | if lhsEndTimestamp < rhsEndTimestamp { 53 | return .orderedAscending 54 | } else if lhsEndTimestamp > rhsEndTimestamp { 55 | return .orderedDescending 56 | } else { 57 | return .orderedSame 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS metadata files 2 | .DS_Store 3 | 4 | # Xcode 5 | # 6 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 7 | 8 | ## User settings 9 | xcuserdata/ 10 | 11 | ## Obj-C/Swift specific 12 | *.hmap 13 | 14 | ## App packaging 15 | *.ipa 16 | *.dSYM.zip 17 | *.dSYM 18 | 19 | ## Playgrounds 20 | timeline.xctimeline 21 | playground.xcworkspace 22 | 23 | # Swift Package Manager 24 | # 25 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 26 | # Packages/ 27 | # Package.pins 28 | # Package.resolved 29 | # *.xcodeproj 30 | # 31 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 32 | # hence it is not needed unless you have added a package configuration file to your project 33 | # .swiftpm 34 | 35 | .build/ 36 | 37 | # CocoaPods 38 | # 39 | # We recommend against adding the Pods directory to your .gitignore. However 40 | # you should judge for yourself, the pros and cons are mentioned at: 41 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 42 | # 43 | # Pods/ 44 | # 45 | # Add this line if you want to avoid checking in source code from the Xcode workspace 46 | # *.xcworkspace 47 | 48 | # Carthage 49 | # 50 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 51 | # Carthage/Checkouts 52 | 53 | Carthage/Build/ 54 | 55 | # Accio dependency management 56 | Dependencies/ 57 | .accio/ 58 | 59 | # fastlane 60 | # 61 | # It is recommended to not store the screenshots in the git repo. 62 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 63 | # For more information about the recommended setup visit: 64 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 65 | 66 | fastlane/report.xml 67 | fastlane/Preview.html 68 | fastlane/screenshots/**/*.png 69 | fastlane/test_output 70 | 71 | # Code Injection 72 | # 73 | # After new code Injection tools there's a generated folder /iOSInjectionProject 74 | # https://github.com/johnno1962/injectionforxcode 75 | 76 | iOSInjectionProject/ 77 | -------------------------------------------------------------------------------- /Comingle/Utilities/CredentialHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CredentialHandler.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 8/5/24. 6 | // 7 | 8 | import AuthenticationServices 9 | import Foundation 10 | import NostrSDK 11 | 12 | final class CredentialHandler: NSObject, ASAuthorizationControllerDelegate { 13 | 14 | private let appState: AppState 15 | 16 | init(appState: AppState) { 17 | self.appState = appState 18 | } 19 | 20 | func checkCredentials() { 21 | let requests: [ASAuthorizationRequest] = [ASAuthorizationPasswordProvider().createRequest()] 22 | let authorizationController = ASAuthorizationController(authorizationRequests: requests) 23 | authorizationController.delegate = self 24 | authorizationController.performRequests() 25 | } 26 | 27 | func saveCredential(keypair: Keypair) { 28 | let npub = keypair.publicKey.npub 29 | let nsec = keypair.privateKey.nsec 30 | 31 | SecAddSharedWebCredential("comingle.co" as CFString, npub as CFString, nsec as CFString, { error in 32 | if let error { 33 | print("⚠️ An error occurred while saving credentials: \(error)") 34 | } 35 | }) 36 | } 37 | 38 | // MARK: - ASAuthorizationControllerDelegate 39 | func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { 40 | guard let credential = authorization.credential as? ASPasswordCredential else { 41 | return 42 | } 43 | 44 | Task { 45 | if let keypair = Keypair(nsec: credential.password) { 46 | appState.signIn(keypair: keypair, relayURLs: appState.relayReadPool.relays.map { $0.url }) 47 | } else if let publicKey = PublicKey(npub: credential.password) { 48 | appState.signIn(publicKey: publicKey, relayURLs: appState.relayReadPool.relays.map { $0.url }) 49 | } 50 | } 51 | } 52 | 53 | func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { 54 | print("⚠️ Warning: authentication failed with error: \(error)") 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Comingle/Models/CalendarListEventSortComparator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarListEventSortComparator.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 8/18/24. 6 | // 7 | 8 | import Foundation 9 | import NostrSDK 10 | 11 | struct CalendarListEventSortComparator: SortComparator { 12 | var order: SortOrder 13 | let appState: AppState 14 | 15 | init(order: SortOrder, appState: AppState) { 16 | self.order = order 17 | self.appState = appState 18 | } 19 | 20 | func compare(_ lhs: CalendarListEvent, _ rhs: CalendarListEvent) -> ComparisonResult { 21 | let comparisonResult = compareForward(lhs, rhs) 22 | switch order { 23 | case .forward: 24 | return comparisonResult 25 | case .reverse: 26 | switch comparisonResult { 27 | case .orderedAscending: 28 | return .orderedDescending 29 | case .orderedDescending: 30 | return .orderedAscending 31 | case .orderedSame: 32 | return .orderedSame 33 | } 34 | } 35 | } 36 | 37 | private func compareForward(_ lhs: CalendarListEvent, _ rhs: CalendarListEvent) -> ComparisonResult { 38 | if lhs == rhs { 39 | return .orderedSame 40 | } 41 | 42 | let publicKeySortComparator = PublicKeySortComparator(order: .forward, appState: appState) 43 | let publicKeyComparison = publicKeySortComparator.compare(lhs.pubkey, rhs.pubkey) 44 | if publicKeyComparison != .orderedSame { 45 | return publicKeyComparison 46 | } 47 | 48 | if lhs.identifier == rhs.identifier { 49 | // Return the newer one first if it's the same replaceable event. 50 | return rhs.createdDate.compare(lhs.createdDate) 51 | } 52 | 53 | let lhsTitle = lhs.title?.trimmedOrNilIfEmpty 54 | let rhsTitle = rhs.title?.trimmedOrNilIfEmpty 55 | 56 | switch (lhsTitle, rhsTitle) { 57 | case (nil, nil): 58 | return lhs.id.compare(rhs.id) 59 | case (_, nil): 60 | return .orderedAscending 61 | case (nil, _): 62 | return .orderedDescending 63 | default: 64 | return lhsTitle!.compare(rhsTitle!) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Comingle.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "0d22ac3b71ede72b2fdac507fb17dcabd424a53c7d4c1845d83367541aaecd22", 3 | "pins" : [ 4 | { 5 | "identity" : "cryptoswift", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", 8 | "state" : { 9 | "revision" : "c9c3df6ab812de32bae61fc0cd1bf6d45170ebf0", 10 | "version" : "1.8.2" 11 | } 12 | }, 13 | { 14 | "identity" : "geohashkit", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/ualch9/GeohashKit.git", 17 | "state" : { 18 | "revision" : "d514b5af0b43bdb0a0912eaa036442f4d02f5661", 19 | "version" : "3.0.0" 20 | } 21 | }, 22 | { 23 | "identity" : "kingfisher", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/onevcat/Kingfisher.git", 26 | "state" : { 27 | "revision" : "2ef543ee21d63734e1c004ad6c870255e8716c50", 28 | "version" : "7.12.0" 29 | } 30 | }, 31 | { 32 | "identity" : "nostr-sdk-ios", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/nostr-sdk/nostr-sdk-ios.git", 35 | "state" : { 36 | "revision" : "9ec53aa94e56c956a379f6770efa49b271e92de5" 37 | } 38 | }, 39 | { 40 | "identity" : "secp256k1.swift", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/GigaBitcoin/secp256k1.swift", 43 | "state" : { 44 | "revision" : "1a14e189def5eaa92f839afdd2faad8e43b61a6e", 45 | "version" : "0.12.2" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-collections", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/apple/swift-collections.git", 52 | "state" : { 53 | "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", 54 | "version" : "1.1.2" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-trie", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/tyiu/swift-trie.git", 61 | "state" : { 62 | "revision" : "4c50bff6c168f74425f70476be62a072980d2da7", 63 | "version" : "0.1.2" 64 | } 65 | } 66 | ], 67 | "version" : 3 68 | } 69 | -------------------------------------------------------------------------------- /Comingle/Utilities/PrivateKeySecureStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrivateKeySecureStorage.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/6/24. 6 | // 7 | 8 | import Foundation 9 | import NostrSDK 10 | import Security 11 | 12 | class PrivateKeySecureStorage { 13 | 14 | static let shared = PrivateKeySecureStorage() 15 | 16 | private let service = "comingle-private-keys" 17 | 18 | func keypair(for publicKey: PublicKey) -> Keypair? { 19 | let query = [ 20 | kSecAttrService: service, 21 | kSecAttrAccount: publicKey.hex, 22 | kSecClass: kSecClassGenericPassword, 23 | kSecReturnData: true, 24 | kSecMatchLimit: kSecMatchLimitOne 25 | ] as [CFString: Any] as CFDictionary 26 | 27 | var result: AnyObject? 28 | let status = SecItemCopyMatching(query, &result) 29 | 30 | if status == errSecSuccess, let data = result as? Data, let privateKeyHex = String(data: data, encoding: .utf8) { 31 | return Keypair(hex: privateKeyHex) 32 | } else { 33 | return nil 34 | } 35 | } 36 | 37 | func store(for keypair: Keypair) { 38 | let query = [ 39 | kSecAttrService: service, 40 | kSecAttrAccount: keypair.publicKey.hex, 41 | kSecClass: kSecClassGenericPassword, 42 | kSecValueData: keypair.privateKey.hex.data(using: .utf8) as Any 43 | ] as [CFString: Any] as CFDictionary 44 | 45 | var status = SecItemAdd(query, nil) 46 | 47 | if status == errSecDuplicateItem { 48 | let query = [ 49 | kSecAttrService: service, 50 | kSecAttrAccount: keypair.publicKey.hex, 51 | kSecClass: kSecClassGenericPassword 52 | ] as [CFString: Any] as CFDictionary 53 | 54 | let updates = [ 55 | kSecValueData: keypair.privateKey.hex.data(using: .utf8) as Any 56 | ] as CFDictionary 57 | 58 | status = SecItemUpdate(query, updates) 59 | } 60 | } 61 | 62 | func delete(for publicKey: PublicKey) { 63 | let query = [ 64 | kSecAttrService: service, 65 | kSecAttrAccount: publicKey.hex, 66 | kSecClass: kSecClassGenericPassword 67 | ] as [CFString: Any] as CFDictionary 68 | 69 | _ = SecItemDelete(query) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Comingle/ComingleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComingleApp.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 5/9/23. 6 | // 7 | 8 | import NostrSDK 9 | import SwiftData 10 | import SwiftUI 11 | 12 | @main 13 | struct ComingleApp: App { 14 | let container: ModelContainer 15 | 16 | @State private var appState: AppState 17 | 18 | init() { 19 | NostrEventValueTransformer.register() 20 | do { 21 | container = try ModelContainer(for: AppSettings.self, PersistentNostrEvent.self) 22 | appState = AppState(modelContext: container.mainContext) 23 | } catch { 24 | fatalError("Failed to create ModelContainer for AppSettings and PersistentNostrEvent.") 25 | } 26 | 27 | loadAppSettings() 28 | updateActiveTab() 29 | loadNostrEvents() 30 | appState.updateRelayPool() 31 | appState.refresh() 32 | } 33 | 34 | var body: some Scene { 35 | WindowGroup { 36 | ContentView(modelContext: container.mainContext) 37 | .environmentObject(appState) 38 | } 39 | .modelContainer(container) 40 | } 41 | 42 | @MainActor 43 | private func updateActiveTab() { 44 | appState.activeTab = .events 45 | } 46 | 47 | @MainActor 48 | private func loadAppSettings() { 49 | var descriptor = FetchDescriptor() 50 | descriptor.fetchLimit = 1 51 | 52 | let existingAppSettings = (try? container.mainContext.fetch(descriptor))?.first 53 | if existingAppSettings == nil { 54 | let newAppSettings = AppSettings() 55 | container.mainContext.insert(newAppSettings) 56 | do { 57 | try container.mainContext.save() 58 | newAppSettings.activeProfile?.profileSettings?.relayPoolSettings?.relaySettingsList.append(RelaySettings(relayURLString: AppState.defaultRelayURLString)) 59 | } catch { 60 | fatalError("Unable to save initial AppSettings.") 61 | } 62 | } 63 | } 64 | 65 | @MainActor 66 | private func loadNostrEvents() { 67 | let descriptor = FetchDescriptor() 68 | let persistentNostrEvents = (try? container.mainContext.fetch(descriptor)) ?? [] 69 | appState.loadPersistentNostrEvents(persistentNostrEvents) 70 | 71 | appState.refreshFollowedPubkeys() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Comingle/Models/PublicKeySortComparator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PublicKeySortComparator.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/30/24. 6 | // 7 | 8 | import Foundation 9 | import NostrSDK 10 | 11 | struct PublicKeySortComparator: SortComparator { 12 | var order: SortOrder 13 | let appState: AppState 14 | 15 | init(order: SortOrder, appState: AppState) { 16 | self.order = order 17 | self.appState = appState 18 | } 19 | 20 | func compare(_ lhs: String, _ rhs: String) -> ComparisonResult { 21 | let comparisonResult = compareForward(lhs, rhs) 22 | switch order { 23 | case .forward: 24 | return comparisonResult 25 | case .reverse: 26 | switch comparisonResult { 27 | case .orderedAscending: 28 | return .orderedDescending 29 | case .orderedDescending: 30 | return .orderedAscending 31 | case .orderedSame: 32 | return .orderedSame 33 | } 34 | } 35 | } 36 | 37 | private func compareForward(_ lhs: String, _ rhs: String) -> ComparisonResult { 38 | if lhs == rhs { 39 | return .orderedSame 40 | } 41 | 42 | switch (appState.followedPubkeys.contains(lhs), appState.followedPubkeys.contains(rhs)) { 43 | case (true, false): 44 | return .orderedAscending 45 | case (false, true): 46 | return .orderedDescending 47 | default: 48 | break 49 | } 50 | 51 | let lhsMetadataEvent = appState.metadataEvents[lhs] 52 | let rhsMetadataEvent = appState.metadataEvents[rhs] 53 | 54 | switch (lhsMetadataEvent, rhsMetadataEvent) { 55 | case (nil, nil): 56 | break 57 | case (nil, _): 58 | return .orderedDescending 59 | case (_, nil): 60 | return .orderedAscending 61 | default: 62 | break 63 | } 64 | 65 | guard let lhsMetadataEvent, let rhsMetadataEvent else { 66 | if let lhsPublicKey = PublicKey(hex: lhs), let rhsPublicKey = PublicKey(hex: rhs) { 67 | return lhsPublicKey.npub.compare(rhsPublicKey.npub) 68 | } else { 69 | return lhs.compare(rhs) 70 | } 71 | } 72 | 73 | return lhsMetadataEvent.resolvedName.localizedCaseInsensitiveCompare(rhsMetadataEvent.resolvedName) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Comingle/Views/TimeZoneSelectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeZoneSelectionView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/29/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftTrie 10 | import SwiftUI 11 | 12 | struct TimeZoneSelectionView: View { 13 | @Environment(\.dismiss) private var dismiss 14 | 15 | @State var date: Date 16 | @Binding private var timeZone: TimeZone? 17 | @State private var search: String = "" 18 | 19 | private let trie = Trie() 20 | 21 | private let knownTimeZones: [TimeZone] 22 | 23 | init(date: Date, timeZone: Binding) { 24 | self.date = date 25 | self._timeZone = timeZone 26 | 27 | self.knownTimeZones = TimeZone.knownTimeZoneIdentifiers 28 | .compactMap { TimeZone(identifier: $0) } 29 | .sorted(using: TimeZoneSortComparator(order: .forward, date: date)) 30 | 31 | self.knownTimeZones 32 | .forEach { timeZone in 33 | _ = trie.insert( 34 | key: timeZone.displayName(for: date), 35 | value: timeZone, 36 | options: [.includeCaseInsensitiveMatches, .includeNonPrefixedMatches] 37 | ) 38 | } 39 | } 40 | 41 | var searchResults: [TimeZone] { 42 | if search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { 43 | knownTimeZones 44 | } else { 45 | trie.find(key: search.localizedLowercase) 46 | .sorted(using: TimeZoneSortComparator(order: .forward, date: date)) 47 | } 48 | } 49 | 50 | var body: some View { 51 | NavigationStack { 52 | List(searchResults, id: \.self, selection: $timeZone) { timeZone in 53 | Text(timeZone.displayName(for: date)) 54 | .bold(self.timeZone?.identifier == timeZone.identifier) 55 | } 56 | .searchable( 57 | text: $search, 58 | placement: .navigationBarDrawer(displayMode: .always), 59 | prompt: String(localized: "Search for time zone", comment: "Placeholder text to prompt user to search for a time zone.") 60 | ) 61 | .onChange(of: timeZone) { 62 | dismiss() 63 | } 64 | } 65 | } 66 | } 67 | 68 | #Preview { 69 | @Previewable @State var timeZone: TimeZone? = TimeZone.autoupdatingCurrent 70 | return TimeZoneSelectionView(date: Date.now, timeZone: $timeZone) 71 | } 72 | -------------------------------------------------------------------------------- /Comingle/Views/Settings/AcknowledgementsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AcknowledgementsView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/29/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AcknowledgementsView: View { 11 | private var dependenciesManager = DependenciesManager() 12 | 13 | var body: some View { 14 | List(dependenciesManager.dependencies) { dependency in 15 | VStack(alignment: .leading) { 16 | Button(action: { 17 | if let urlString = dependency.url, let url = URL(string: urlString) { 18 | UIApplication.shared.open(url) 19 | } 20 | }, label: { 21 | LabeledContent(dependency.name, value: dependency.version) 22 | }) 23 | } 24 | } 25 | .navigationTitle(String(localized: "Acknowledgements", comment: "View for seeing the acknowledgements of projects that this app depends on.")) 26 | } 27 | } 28 | 29 | struct Dependency: Identifiable { 30 | let id = UUID() 31 | let name: String 32 | let version: String 33 | let url: String? 34 | } 35 | 36 | @Observable class DependenciesManager { 37 | var dependencies: [Dependency] = [] 38 | 39 | init() { 40 | loadDependencies() 41 | } 42 | 43 | func loadDependencies() { 44 | // Add your dependencies here 45 | dependencies = [ 46 | Dependency(name: "Comingle Logo", version: "The: Daniel⚡️", url: Utilities.shared.externalNostrProfileURL(npub: "npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx")?.absoluteString), 47 | Dependency(name: "CryptoSwift", version: "1.8.2", url: "https://github.com/krzyzanowskim/CryptoSwift"), 48 | Dependency(name: "GeohashKit", version: "3.0.0", url: "https://github.com/ualch9/GeohashKit"), 49 | Dependency(name: "Kingfisher", version: "7.12.0", url: "https://github.com/onevcat/Kingfisher"), 50 | Dependency(name: "Nostr SDK for Apple Platforms", version: "9ec53aa", url: "https://github.com/nostr-sdk/nostr-sdk-ios"), 51 | Dependency(name: "Robohash", version: "Cats - David Revoy", url: "https://robohash.org/"), 52 | Dependency(name: "secp256k1", version: "0.12.2", url: "https://github.com/21-DOT-DEV/swift-secp256k1"), 53 | Dependency(name: "swift-collections", version: "1.1.2", url: "https://github.com/apple/swift-collections"), 54 | Dependency(name: "SwiftTrie", version: "0.1.2", url: "https://github.com/tyiu/swift-trie") 55 | ] 56 | } 57 | } 58 | 59 | #Preview { 60 | AcknowledgementsView() 61 | } 62 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 0.1.0 (8) - 2024-01-27 4 | 5 | ### Fixed 6 | - Removed xcstrings-tool-plugin to improve performance 7 | - Improved memory usage by removing unnecessary fields from what is cached in the tries 8 | 9 | ## 0.1.0 (7) - 2024-11-18 10 | 11 | ### Fixed 12 | - Replacing old calendars did not update calendar search 13 | - Padding issues with event list 14 | 15 | ## 0.1.0 (6) - 2024-08-22 16 | 17 | ### Added 18 | - Condensed profile pictures of invitees and RSVPs on event list 19 | 20 | ### Fixed 21 | - Time zone not being set on event creation when it is the system time zone 22 | - Newly created events and calendars were not searchable 23 | - Events were not searchable by summary 24 | 25 | ## 0.1.0 (5) - 2024-08-21 26 | 27 | ### Added 28 | - Search by username, calendar name 29 | - Profile description 30 | 31 | ### Changed 32 | - Change calendar description component from disclosure group to just plain text with a collapse button 33 | 34 | ### Fixed 35 | - Case sensitivity when searching event details 36 | - Erroneously showing events from followed pubkeys in calendar view and profile view 37 | 38 | ## 0.1.0 (4) - 2024-08-20 39 | 40 | ### Added 41 | - Search by event details, naddr, nevent, npub 42 | 43 | ### Changed 44 | - Consolidated Home and Explore tabs 45 | 46 | ### Fixed 47 | - Signing in with public key would use private key if it used to exist in the keychain 48 | 49 | ## 0.1.0 (3) - 2024-08-18 50 | 51 | ### Added 52 | - Relay state visual indicators and added RSVP retry publish button 53 | - Deletion requests 54 | - Calendars view 55 | 56 | ### Changed 57 | - Updated Explore tab image to be a globe 58 | - Abbreviated public key npub when there is no display name or username 59 | - Renamed Nostr event deletion to retraction 60 | 61 | ### Fixed 62 | - Hide map if geohash is an empty string 63 | - Event creation participant list bug and start/end time zone bug 64 | - Relay url and role were not being set on EventCreationParticipant 65 | - Time zone bug in event creation when setting time zone toggle is off 66 | - Race condition for handling relay responses 67 | - Bug with images that do not end with a file extension 68 | 69 | ## 0.1.0 (2) - 2024-08-06 70 | 71 | ### Added 72 | - Profile creation 73 | - Event deletion 74 | - Toolbar menu item to copy njump.me URL for event 75 | 76 | ### Changed 77 | - Profile name resolution now prefers display name over name 78 | 79 | ### Fixed 80 | - Sign out bug where active tab is not switched 81 | - Event creation image URL validation 82 | - Bug where updating relay pool settings do not get reflected in the active relay pool 83 | - Alignment of event relay list 84 | 85 | ## 0.1.0 (1) - 2024-08-04 86 | 87 | Initial release of Comingle! 88 | -------------------------------------------------------------------------------- /Comingle/Views/Settings/AppearanceSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppearanceSettingsView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/10/24. 6 | // 7 | 8 | import SwiftData 9 | import SwiftUI 10 | 11 | struct AppearanceSettingsView: View { 12 | 13 | @EnvironmentObject var appState: AppState 14 | 15 | @State private var viewModel: ViewModel 16 | 17 | init(modelContext: ModelContext, publicKeyHex: String?) { 18 | let viewModel = ViewModel(modelContext: modelContext, publicKeyHex: publicKeyHex) 19 | _viewModel = State(initialValue: viewModel) 20 | } 21 | 22 | var body: some View { 23 | List { 24 | Section( 25 | content: { 26 | Picker(selection: $viewModel.timeZonePreference, label: Text("Time Zone", comment: "Label for time zone setting.")) { 27 | ForEach(TimeZonePreference.allCases, id: \.self) { preference in 28 | Text(preference.localizedString) 29 | .tag(preference) 30 | } 31 | } 32 | }, 33 | header: { 34 | Text("Appearance", comment: "Settings section for appearance of the app.") 35 | } 36 | ) 37 | } 38 | } 39 | } 40 | 41 | extension AppearanceSettingsView { 42 | @Observable class ViewModel { 43 | let publicKeyHex: String? 44 | let modelContext: ModelContext 45 | var appearanceSettings: AppearanceSettings? 46 | 47 | init(modelContext: ModelContext, publicKeyHex: String?) { 48 | self.modelContext = modelContext 49 | self.publicKeyHex = publicKeyHex 50 | fetchData() 51 | } 52 | 53 | var timeZonePreference: TimeZonePreference { 54 | get { 55 | appearanceSettings?.timeZonePreference ?? .event 56 | } 57 | set { 58 | if let appearanceSettings { 59 | appearanceSettings.timeZonePreference = newValue 60 | } 61 | } 62 | } 63 | 64 | func fetchData() { 65 | do { 66 | var descriptor = FetchDescriptor( 67 | predicate: #Predicate { $0.publicKeyHex == publicKeyHex } 68 | ) 69 | descriptor.fetchLimit = 1 70 | 71 | self.appearanceSettings = try modelContext.fetch(descriptor).first 72 | } catch { 73 | print("Appearance settings fetch failed for publicKeyHex=\(publicKeyHex ?? "nil")") 74 | } 75 | } 76 | } 77 | } 78 | 79 | //#Preview { 80 | // AppearanceSettingsView() 81 | //} 82 | -------------------------------------------------------------------------------- /Comingle/Models/RSVPSortComparator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RSVPSortComparator.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/30/24. 6 | // 7 | 8 | import Foundation 9 | import NostrSDK 10 | 11 | struct RSVPSortComparator: SortComparator { 12 | var order: SortOrder 13 | let appState: AppState 14 | 15 | init(order: SortOrder, appState: AppState) { 16 | self.order = order 17 | self.appState = appState 18 | } 19 | 20 | func compare(_ lhs: CalendarEventRSVP, _ rhs: CalendarEventRSVP) -> ComparisonResult { 21 | let comparisonResult = compareForward(lhs, rhs) 22 | switch order { 23 | case .forward: 24 | return comparisonResult 25 | case .reverse: 26 | switch comparisonResult { 27 | case .orderedAscending: 28 | return .orderedDescending 29 | case .orderedDescending: 30 | return .orderedAscending 31 | case .orderedSame: 32 | return .orderedSame 33 | } 34 | } 35 | } 36 | 37 | private func compareForward(_ lhs: CalendarEventRSVP, _ rhs: CalendarEventRSVP) -> ComparisonResult { 38 | if lhs == rhs { 39 | return .orderedSame 40 | } 41 | 42 | switch (appState.followedPubkeys.contains(lhs.pubkey), appState.followedPubkeys.contains(rhs.pubkey)) { 43 | case (true, false): 44 | return .orderedAscending 45 | case (false, true): 46 | return .orderedDescending 47 | default: 48 | break 49 | } 50 | 51 | let lhsMetadataEvent = appState.metadataEvents[lhs.pubkey] 52 | let rhsMetadataEvent = appState.metadataEvents[rhs.pubkey] 53 | 54 | switch (lhsMetadataEvent, rhsMetadataEvent) { 55 | case (nil, nil): 56 | break 57 | case (nil, _): 58 | return .orderedDescending 59 | case (_, nil): 60 | return .orderedAscending 61 | default: 62 | break 63 | } 64 | 65 | switch (lhs.status, rhs.status) { 66 | case (nil, nil), (.accepted, .accepted), (.tentative, .tentative), (.declined, .declined), (.unknown, .unknown): 67 | break 68 | case (.accepted, _): 69 | return .orderedAscending 70 | case (_, .accepted): 71 | return .orderedDescending 72 | case (.tentative, _): 73 | return .orderedAscending 74 | case (_, .tentative): 75 | return .orderedDescending 76 | case (.declined, _): 77 | return .orderedDescending 78 | case (_, .declined): 79 | return .orderedAscending 80 | case (.unknown, _): 81 | return .orderedAscending 82 | default: 83 | break 84 | } 85 | 86 | let publicKeySortComparator = PublicKeySortComparator(order: .forward, appState: appState) 87 | return publicKeySortComparator.compare(lhs.pubkey, rhs.pubkey) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Comingle/Views/CalendarsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarsView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 8/16/24. 6 | // 7 | 8 | import Kingfisher 9 | import NostrSDK 10 | import SwiftUI 11 | 12 | struct CalendarsView: View { 13 | 14 | @EnvironmentObject var appState: AppState 15 | @StateObject private var searchViewModel = SearchViewModel() 16 | 17 | private var calendarListEvents: [CalendarListEvent] { 18 | let calendarsSearchResults: [CalendarListEvent] 19 | 20 | if let searchText = searchViewModel.debouncedSearchText.trimmedOrNilIfEmpty { 21 | calendarsSearchResults = appState.calendarsTrie.find(key: searchText.localizedLowercase) 22 | .compactMap { appState.calendarListEvents[$0] } 23 | .filter { !$0.calendarEventCoordinateList.isEmpty } 24 | } else { 25 | calendarsSearchResults = appState.calendarListEvents.values 26 | .filter { !$0.calendarEventCoordinateList.isEmpty } 27 | } 28 | 29 | return calendarsSearchResults 30 | .sorted(using: CalendarListEventSortComparator(order: .forward, appState: appState)) 31 | } 32 | 33 | func imageView(_ imageURL: URL) -> some View { 34 | KFImage.url(imageURL) 35 | .resizable() 36 | .placeholder { ProgressView() } 37 | .scaledToFit() 38 | .frame(maxWidth: 100, maxHeight: 200) 39 | } 40 | 41 | func titleAndProfileView(_ calendarListEvent: CalendarListEvent) -> some View { 42 | VStack(alignment: .leading) { 43 | Text(calendarListEvent.title?.trimmedOrNilIfEmpty ?? calendarListEvent.firstValueForRawTagName("name")?.trimmedOrNilIfEmpty ?? String(localized: "No Name", comment: "Text to indicate that there is no title for the calendar.")) 44 | .font(.headline) 45 | 46 | Divider() 47 | 48 | ProfilePictureAndNameView(publicKeyHex: calendarListEvent.pubkey) 49 | } 50 | } 51 | 52 | var body: some View { 53 | List { 54 | ForEach(calendarListEvents, id: \.self) { calendarListEvent in 55 | Section( 56 | content: { 57 | NavigationLink(destination: { 58 | if let coordinates = calendarListEvent.replaceableEventCoordinates()?.tag.value { 59 | CalendarListEventView(calendarListEventCoordinates: coordinates) 60 | } 61 | }, label: { 62 | HStack { 63 | titleAndProfileView(calendarListEvent) 64 | if let imageURL = calendarListEvent.imageURL { 65 | imageView(imageURL) 66 | } 67 | } 68 | }) 69 | } 70 | ) 71 | } 72 | } 73 | .navigationBarTitleDisplayMode(.inline) 74 | .searchable(text: $searchViewModel.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: String(localized: "Search for calendars", comment: "Placeholder text to prompt user to search calendars")) 75 | } 76 | } 77 | 78 | //#Preview { 79 | // CalendarsView() 80 | //} 81 | -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/ComingleLogo.imageset/ComingleLogoDark 1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/ComingleLogo.imageset/ComingleLogoDark 2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/ComingleLogo.imageset/ComingleLogoDark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/ComingleLogo.imageset/ComingleLogoLight 1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/ComingleLogo.imageset/ComingleLogoLight 2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Comingle/Assets/Assets.xcassets/ComingleLogo.imageset/ComingleLogoLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Comingle/Launch Screen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /Comingle/Views/ProfileView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/7/24. 6 | // 7 | 8 | import Kingfisher 9 | import NostrSDK 10 | import SwiftUI 11 | 12 | struct ProfileView: View { 13 | 14 | @EnvironmentObject var appState: AppState 15 | 16 | @State var publicKeyHex: String 17 | 18 | @State private var isDescriptionExpanded: Bool = false 19 | 20 | private let maxDescriptionLength = 140 21 | 22 | private var nostrProfileURL: URL? { 23 | guard let publicKey = PublicKey(hex: publicKeyHex) else { 24 | return nil 25 | } 26 | 27 | return Utilities.shared.externalNostrProfileURL(npub: publicKey.npub) 28 | } 29 | 30 | var body: some View { 31 | VStack { 32 | ProfilePictureAndNameView(publicKeyHex: publicKeyHex) 33 | if let publicKey = PublicKey(hex: publicKeyHex) { 34 | Text(publicKey.npub) 35 | .font(.subheadline) 36 | .textSelection(.enabled) 37 | .padding() 38 | } 39 | 40 | if let metadataEvent = appState.metadataEvents[publicKeyHex], let description = metadataEvent.userMetadata?.about?.trimmedOrNilIfEmpty { 41 | VStack(alignment: .leading) { 42 | if isDescriptionExpanded || description.count <= maxDescriptionLength { 43 | Text(.init(description)) 44 | .font(.subheadline) 45 | } else { 46 | Text(.init(description.prefix(maxDescriptionLength) + "...")) 47 | .font(.subheadline) 48 | } 49 | 50 | if description.count > maxDescriptionLength { 51 | Button(action: { 52 | isDescriptionExpanded.toggle() 53 | }, label: { 54 | if isDescriptionExpanded { 55 | Text("Show Less", comment: "Button to hide truncated text.") 56 | .font(.subheadline) 57 | } else { 58 | Text("Show More", comment: "Button to reveal the rest of truncated text.") 59 | .font(.subheadline) 60 | } 61 | }) 62 | } 63 | } 64 | } 65 | 66 | EventListView(eventListType: .profile(publicKeyHex)) 67 | } 68 | .navigationBarTitleDisplayMode(.inline) 69 | .toolbar { 70 | ToolbarItem { 71 | Menu { 72 | if let publicKey = PublicKey(hex: publicKeyHex) { 73 | Button(action: { 74 | UIPasteboard.general.string = publicKey.npub 75 | }, label: { 76 | Label(String(localized: "Copy Public Key", comment: "Button to copy a user's public key."), systemImage: "key") 77 | }) 78 | 79 | if let nostrProfileURL { 80 | Button(action: { 81 | UIApplication.shared.open(nostrProfileURL) 82 | }, label: { 83 | Label(String(localized: "Open Profile in Default Nostr App", comment: "Button to open profile in default Nostr app."), systemImage: "link") 84 | }) 85 | } 86 | } 87 | } label: { 88 | Label(String(localized: "Menu", comment: "Label for drop down menu in calendar event view."), systemImage: "ellipsis.circle") 89 | } 90 | } 91 | } 92 | } 93 | } 94 | 95 | //struct ProfileView_Previews: PreviewProvider { 96 | // 97 | // @State static var appState = AppState() 98 | // 99 | // static var previews: some View { 100 | // ProfileView(publicKeyHex: "fake-pubkey") 101 | // .environmentObject(appState) 102 | // } 103 | //} 104 | -------------------------------------------------------------------------------- /Comingle/Views/ParticipantSearchView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParticipantSearchView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/30/24. 6 | // 7 | 8 | import NostrSDK 9 | import OrderedCollections 10 | import SwiftUI 11 | 12 | struct ParticipantSearchView: View { 13 | @Environment(\.dismiss) private var dismiss 14 | 15 | @State var appState: AppState 16 | @Binding private var participants: Set 17 | 18 | @StateObject private var searchViewModel = SearchViewModel() 19 | 20 | @State private var roleText: String = "" 21 | 22 | let eventCreationParticipantSortComparator: EventCreationParticipantSortComparator 23 | 24 | init(appState: AppState, participants: Binding>) { 25 | self.appState = appState 26 | self._participants = participants 27 | 28 | eventCreationParticipantSortComparator = EventCreationParticipantSortComparator(order: .forward, appState: appState) 29 | } 30 | 31 | var trimmedParticipantSearch: String { 32 | searchViewModel.debouncedSearchText.trimmingCharacters(in: .whitespacesAndNewlines) 33 | } 34 | 35 | var participantSearchResults: OrderedSet { 36 | let trimmedParticipantSearch = trimmedParticipantSearch 37 | 38 | if trimmedParticipantSearch.isEmpty { 39 | let sortedParticipants = OrderedSet(participants.sorted(using: eventCreationParticipantSortComparator)) 40 | 41 | if !appState.followedPubkeys.isEmpty { 42 | return sortedParticipants.union( 43 | appState.followedPubkeys 44 | .filter { appState.metadataEvents[$0] != nil } 45 | .map { EventCreationParticipant(publicKeyHex: $0) } 46 | .sorted(using: eventCreationParticipantSortComparator) 47 | ) 48 | } else { 49 | return sortedParticipants 50 | } 51 | } else { 52 | let searchResults = appState.pubkeyTrie.find(key: trimmedParticipantSearch.localizedLowercase) 53 | .map { EventCreationParticipant(publicKeyHex: $0) } 54 | .sorted(using: eventCreationParticipantSortComparator) 55 | 56 | if !searchResults.isEmpty { 57 | return OrderedSet(searchResults) 58 | } 59 | 60 | if let publicKey = PublicKey(npub: trimmedParticipantSearch) { 61 | return OrderedSet(arrayLiteral: EventCreationParticipant(publicKeyHex: publicKey.hex)) 62 | } 63 | 64 | return [] 65 | } 66 | } 67 | 68 | var body: some View { 69 | List { 70 | ForEach(participantSearchResults, id: \.self) { participant in 71 | VStack { 72 | Button { 73 | if participants.contains(participant) { 74 | participants.remove(participant) 75 | } else { 76 | participants.insert(participant) 77 | } 78 | } label: { 79 | HStack { 80 | ProfilePictureAndNameView(publicKeyHex: participant.publicKeyHex) 81 | .environmentObject(appState) 82 | 83 | Spacer() 84 | 85 | if participants.contains(participant) { 86 | Image(systemName: "checkmark") 87 | } 88 | } 89 | .foregroundStyle(participants.contains(participant) ? .accent : .primary) 90 | } 91 | 92 | if participants.contains(participant) { 93 | let roleBinding = Binding( 94 | get: { 95 | participant.role 96 | }, 97 | set: { 98 | participant.role = $0 99 | } 100 | ) 101 | TextField(String(localized: "Role", comment: "Placeholder text for entry of a role of a participant to an event."), text: roleBinding) 102 | } 103 | } 104 | } 105 | } 106 | .searchable(text: $searchViewModel.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: String(localized: "Search for participant", comment: "Placeholder text to prompt user to search for a participant to invite to an event.")) 107 | } 108 | } 109 | 110 | //#Preview { 111 | // ProfileSearchView() 112 | //} 113 | -------------------------------------------------------------------------------- /Comingle/Views/CalendarListEventView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarListEventView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 8/16/24. 6 | // 7 | 8 | import Kingfisher 9 | import NostrSDK 10 | import SwiftUI 11 | 12 | struct CalendarListEventView: View { 13 | 14 | @EnvironmentObject private var appState: AppState 15 | 16 | @State var calendarListEventCoordinates: String 17 | 18 | @State private var isDescriptionExpanded: Bool = false 19 | 20 | private let maxDescriptionLength = 140 21 | 22 | private var calendarListEvent: CalendarListEvent? { 23 | appState.calendarListEvents[calendarListEventCoordinates] 24 | } 25 | 26 | private var naddr: String? { 27 | if let calendarListEvent { 28 | let relays = appState.persistentNostrEvent(calendarListEvent.id)?.relays ?? [] 29 | return try? calendarListEvent.shareableEventCoordinates(relayURLStrings: relays.map { $0.absoluteString }) 30 | } 31 | return nil 32 | } 33 | 34 | private var calendarURL: URL? { 35 | if let naddr, let njumpURL = URL(string: "https://njump.me/\(naddr)"), UIApplication.shared.canOpenURL(njumpURL) { 36 | return njumpURL 37 | } 38 | return nil 39 | } 40 | 41 | var body: some View { 42 | if let calendarListEvent { 43 | VStack { 44 | if let imageURL = calendarListEvent.imageURL { 45 | KFImage.url(imageURL) 46 | .resizable() 47 | .placeholder { ProgressView() } 48 | .scaledToFit() 49 | .frame(width: 40) 50 | .clipShape(.circle) 51 | } 52 | 53 | Text(calendarListEvent.title ?? calendarListEvent.firstValueForRawTagName("name") ?? String(localized: "No Name", comment: "Text to indicate that there is no title for the calendar.")) 54 | .font(.headline) 55 | 56 | NavigationLink(destination: ProfileView(publicKeyHex: calendarListEvent.pubkey)) { 57 | ProfilePictureAndNameView(publicKeyHex: calendarListEvent.pubkey) 58 | } 59 | 60 | if let description = calendarListEvent.content.trimmedOrNilIfEmpty { 61 | VStack(alignment: .leading) { 62 | if isDescriptionExpanded || description.count <= maxDescriptionLength { 63 | Text(.init(description)) 64 | .font(.subheadline) 65 | } else { 66 | Text(.init(description.prefix(maxDescriptionLength) + "...")) 67 | .font(.subheadline) 68 | } 69 | 70 | if description.count > maxDescriptionLength { 71 | Button(action: { 72 | isDescriptionExpanded.toggle() 73 | }, label: { 74 | if isDescriptionExpanded { 75 | Text("Show Less", comment: "Button to hide truncated text.") 76 | .font(.subheadline) 77 | } else { 78 | Text("Show More", comment: "Button to reveal the rest of truncated text.") 79 | .font(.subheadline) 80 | } 81 | }) 82 | } 83 | } 84 | } 85 | 86 | EventListView(eventListType: .calendar(calendarListEventCoordinates)) 87 | } 88 | .navigationBarTitleDisplayMode(.inline) 89 | .toolbar { 90 | ToolbarItem { 91 | Menu { 92 | Button(action: { 93 | UIPasteboard.general.string = naddr 94 | }, label: { 95 | Text("Copy Calendar ID", comment: "Button to copy the ID of the calendar.") 96 | }) 97 | 98 | if let calendarURL { 99 | Button(action: { 100 | UIPasteboard.general.string = calendarURL.absoluteString 101 | }, label: { 102 | Text("Copy Calendar URL", comment: "Button to copy the URL of the calendar.") 103 | }) 104 | } 105 | } label: { 106 | Label(String(localized: "Menu", comment: "Label for drop down menu in calendar event view."), systemImage: "ellipsis.circle") 107 | } 108 | } 109 | } 110 | } else { 111 | EmptyView() 112 | } 113 | } 114 | } 115 | 116 | //#Preview { 117 | // CalendarListEventView() 118 | //} 119 | -------------------------------------------------------------------------------- /Comingle/Views/LocationSearchView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationSearchView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 8/1/24. 6 | // 7 | 8 | import GeohashKit 9 | import MapKit 10 | import SwiftUI 11 | 12 | struct LocationSearchView: View { 13 | @Environment(\.dismiss) private var dismiss 14 | 15 | @StateObject private var autocompleteManager = MKAutocompleteManager() 16 | 17 | @Binding var location: String 18 | @Binding var geohash: String 19 | 20 | @State private var mapItem: MKMapItem? 21 | @State private var newMapItem: MKMapItem? 22 | 23 | var body: some View { 24 | VStack { 25 | TextField(String(localized: "Search for location", comment: "Placeholder text to prompt user to search for a location for the event."), text: $autocompleteManager.searchText) 26 | .textFieldStyle(RoundedBorderTextFieldStyle()) 27 | .autocorrectionDisabled() 28 | .textInputAutocapitalization(.never) 29 | .padding() 30 | .onChange(of: autocompleteManager.searchText) { oldValue, newValue in 31 | if oldValue.trimmingCharacters(in: .whitespacesAndNewlines) != newValue.trimmingCharacters(in: .whitespacesAndNewlines) { 32 | mapItem = nil 33 | autocompleteManager.updateSearchResults() 34 | } 35 | if let newMapItem { 36 | mapItem = newMapItem 37 | self.newMapItem = nil 38 | } 39 | } 40 | 41 | if let searchGeohash { 42 | Map(bounds: MapCameraBounds(centerCoordinateBounds: searchGeohash.region)) { 43 | Marker(autocompleteManager.searchText, coordinate: searchGeohash.region.center) 44 | } 45 | .frame(height: 250) 46 | } 47 | 48 | List(autocompleteManager.completions, id: \.self) { completion in 49 | Button(action: { 50 | performSearch(for: completion) 51 | }, label: { 52 | VStack(alignment: .leading) { 53 | Text(completion.title) 54 | .font(.headline) 55 | Text(completion.subtitle) 56 | .font(.subheadline) 57 | } 58 | }) 59 | } 60 | } 61 | .toolbar { 62 | Button(action: { 63 | location = autocompleteManager.searchText 64 | geohash = searchGeohash?.geohash ?? "" 65 | dismiss() 66 | }, label: { 67 | Text("Add Location", comment: "Button to add location to event.") 68 | }) 69 | } 70 | .task { 71 | autocompleteManager.searchText = location 72 | } 73 | } 74 | 75 | private func performSearch(for completion: MKLocalSearchCompletion) { 76 | let query = completion.displayName 77 | let searchRequest = MKLocalSearch.Request() 78 | searchRequest.naturalLanguageQuery = completion.displayName 79 | 80 | let localSearch = MKLocalSearch(request: searchRequest) 81 | localSearch.start { (response, error) in 82 | guard let response = response, let mapItem = response.mapItems.first else { 83 | print("Search error: \(error?.localizedDescription ?? "Unknown error")") 84 | self.newMapItem = nil 85 | autocompleteManager.searchText = query 86 | return 87 | } 88 | 89 | self.newMapItem = mapItem 90 | autocompleteManager.searchText = mapItem.displayName 91 | } 92 | } 93 | 94 | private var trimmedGeohash: String { 95 | geohash.trimmingCharacters(in: .whitespacesAndNewlines) 96 | } 97 | 98 | private var searchGeohash: Geohash? { 99 | if let mapItem { 100 | let coordinate = mapItem.placemark.coordinate 101 | 102 | // Precision of 10 gives an area ≤ 1.19m x 0.596m 103 | return Geohash(coordinates: (coordinate.latitude, coordinate.longitude), precision: 10) 104 | } else { 105 | let trimmedGeohash = trimmedGeohash 106 | if location == autocompleteManager.searchText, !trimmedGeohash.isEmpty { 107 | return Geohash(geohash: trimmedGeohash) 108 | } else { 109 | return nil 110 | } 111 | } 112 | } 113 | } 114 | 115 | extension MKLocalSearchCompletion { 116 | var displayName: String { 117 | "\(title), \(subtitle)" 118 | } 119 | } 120 | 121 | extension MKMapItem { 122 | var displayName: String { 123 | var result: [String] = [] 124 | if let title = placemark.title { 125 | if let name, !title.starts(with: name) { 126 | result.append(name) 127 | } 128 | result.append(title) 129 | } 130 | if let subtitle = placemark.subtitle { 131 | result.append(subtitle) 132 | } 133 | return result.joined(separator: ", ") 134 | } 135 | } 136 | 137 | //#Preview { 138 | // LocationSearchView() 139 | //} 140 | -------------------------------------------------------------------------------- /Comingle/Views/Settings/KeysSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeysSettingsView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/10/24. 6 | // 7 | 8 | import Combine 9 | import LocalAuthentication 10 | import NostrSDK 11 | import SwiftUI 12 | 13 | struct KeysSettingsView: View { 14 | 15 | let publicKey: PublicKey 16 | @State private var privateKeyNsec: String = "" 17 | 18 | @EnvironmentObject var appState: AppState 19 | 20 | @State private var validPrivateKey: Bool = false 21 | 22 | @State private var incorrectPrivateKeyAlertPresented: Bool = false 23 | 24 | @State private var hasCopiedPublicKey: Bool = false 25 | 26 | var body: some View { 27 | List { 28 | Section( 29 | content: { 30 | HStack { 31 | Button(action: { 32 | UIPasteboard.general.string = publicKey.npub 33 | hasCopiedPublicKey = true 34 | }, label: { 35 | HStack { 36 | Text(publicKey.npub) 37 | .textContentType(.username) 38 | .lineLimit(2) 39 | .minimumScaleFactor(0.1) 40 | 41 | if hasCopiedPublicKey { 42 | Image(systemName: "doc.on.doc.fill") 43 | } else { 44 | Image(systemName: "doc.on.doc") 45 | } 46 | } 47 | }) 48 | .foregroundStyle(.primary) 49 | } 50 | }, 51 | header: { 52 | Text("Public Key", comment: "Section header for public key.") 53 | } 54 | ) 55 | 56 | Section( 57 | content: { 58 | HStack { 59 | SecureField(String(localized: "nsec...", comment: "Placeholder text to prompt user to enter private key."), text: $privateKeyNsec) 60 | .disabled(validPrivateKey) 61 | .autocorrectionDisabled(false) 62 | .textContentType(.password) 63 | .textInputAutocapitalization(.never) 64 | .onReceive(Just(privateKeyNsec)) { newValue in 65 | let filtered = newValue.trimmingCharacters(in: .whitespacesAndNewlines) 66 | privateKeyNsec = filtered 67 | 68 | if let keypair = Keypair(nsec: filtered) { 69 | if keypair.publicKey == publicKey { 70 | appState.privateKeySecureStorage.store(for: keypair) 71 | privateKeyNsec = keypair.privateKey.nsec 72 | validPrivateKey = true 73 | } else { 74 | validPrivateKey = false 75 | incorrectPrivateKeyAlertPresented = true 76 | } 77 | } else { 78 | validPrivateKey = false 79 | } 80 | } 81 | } 82 | }, 83 | header: { 84 | Text("Private Key", comment: "Section header for private key.") 85 | }, 86 | footer: { 87 | if validPrivateKey { 88 | Text("You have entered a private key, which means you will be able to view, create, modify, and RSVP to events.", comment: "Footer text indicating what it means to have a private key entered.") 89 | } else if privateKeyNsec.isEmpty { 90 | Text("You have not entered a private key, which means you will be not be able to create, modify, or RSVP to events.", comment: "Footer text indicating what it means to not have a private key entered.") 91 | } else { 92 | Text("You have entered an incorrect private key, which means you will be not be able to create, modify, or RSVP to events.", comment: "Footer text indicating what it means to have an incorrect private key entered.") 93 | } 94 | } 95 | ) 96 | } 97 | .alert( 98 | Text("Private key does not match the public key.", comment: "Alert message to tell user that the private key that they entered does not match the public key."), 99 | isPresented: $incorrectPrivateKeyAlertPresented 100 | ) { 101 | Button(String(localized: "OK", comment: "Button to acknowledge and dismiss an alert.")) { 102 | privateKeyNsec = "" 103 | } 104 | } 105 | .task { 106 | if let nsec = appState.privateKeySecureStorage.keypair(for: publicKey)?.privateKey.nsec { 107 | privateKeyNsec = nsec 108 | validPrivateKey = true 109 | } else { 110 | privateKeyNsec = "" 111 | validPrivateKey = false 112 | } 113 | } 114 | } 115 | } 116 | 117 | #Preview { 118 | KeysSettingsView(publicKey: PublicKey(hex: "c3e6982c7f93e443d99f2d22c3d6fc6ba61475af11bcf289f927a7b905fffe51")!) 119 | } 120 | -------------------------------------------------------------------------------- /Comingle/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 5/9/23. 6 | // 7 | 8 | import Kingfisher 9 | import NostrSDK 10 | import SwiftData 11 | import SwiftUI 12 | 13 | struct ContentView: View { 14 | 15 | let modelContext: ModelContext 16 | @EnvironmentObject var appState: AppState 17 | 18 | @State var isShowingCreationConfirmation: Bool = false 19 | 20 | init(modelContext: ModelContext) { 21 | self.modelContext = modelContext 22 | } 23 | 24 | var body: some View { 25 | ScrollViewReader { scrollViewProxy in 26 | NavigationStack { 27 | VStack { 28 | if appState.activeTab == .events { 29 | NavigationStack { 30 | EventListView(eventListType: .all) 31 | .navigationBarTitleDisplayMode(.inline) 32 | } 33 | } 34 | 35 | if appState.activeTab == .calendars { 36 | NavigationStack { 37 | CalendarsView() 38 | } 39 | } 40 | 41 | CustomTabBar(selectedTab: $appState.activeTab, isSignedIn: appState.publicKey != nil) { 42 | withAnimation { 43 | scrollViewProxy.scrollTo("event-list-view-top") 44 | } 45 | } 46 | } 47 | .confirmationDialog(String(localized: "Create a ..."), isPresented: $isShowingCreationConfirmation) { 48 | addEventConfirmationDialogAction() 49 | } 50 | .toolbar { 51 | ToolbarItem(placement: .topBarLeading) { 52 | Button(action: { 53 | isShowingCreationConfirmation = true 54 | }, label: { 55 | Image(systemName: "plus.circle") 56 | .opacity(appState.keypair != nil ? 1 : 0) 57 | }) 58 | .disabled(appState.keypair == nil) 59 | } 60 | 61 | ToolbarItem(placement: .principal) { 62 | Image("ComingleLogo") 63 | .resizable() 64 | .aspectRatio(contentMode: .fit) 65 | .frame(maxHeight: 20, alignment: .center) 66 | } 67 | 68 | ToolbarItem(placement: .topBarTrailing) { 69 | NavigationLink( 70 | destination: { 71 | SettingsView(appState: appState) 72 | }, 73 | label: { 74 | if let keypair = appState.keypair { 75 | ProfilePictureView(publicKeyHex: keypair.publicKey.hex) 76 | } else { 77 | ImageOverlayView(imageSystemName: "lock.fill", overlayBackgroundColor: .accent) { 78 | if let publicKey = appState.publicKey { 79 | ProfilePictureView(publicKeyHex: publicKey.hex) 80 | } else { 81 | GuestProfilePictureView() 82 | .foregroundColor(.primary) 83 | } 84 | } 85 | } 86 | } 87 | ) 88 | } 89 | } 90 | } 91 | } 92 | } 93 | 94 | @ViewBuilder func addEventConfirmationDialogAction() -> some View { 95 | NavigationLink( 96 | destination: { 97 | CreateOrModifyEventView(appState: appState) 98 | }, 99 | label: { 100 | Text("Create Event") 101 | } 102 | ) 103 | 104 | // Comment out calendar creation for now while it's not ready yet. 105 | // NavigationLink( 106 | // destination: { 107 | // CreateOrModifyCalendarView(appState: appState) 108 | // }, 109 | // label: { 110 | // Text("Create Calendar") 111 | // } 112 | // ) 113 | } 114 | } 115 | 116 | struct CustomTabBar: View { 117 | @Binding var selectedTab: HomeTabs 118 | 119 | let isSignedIn: Bool 120 | let onTapAction: () -> Void 121 | 122 | var body: some View { 123 | HStack { 124 | CustomTabBarItem(iconName: "house.fill", title: String(localized: "Home", comment: "Tab label for the home view."), tab: HomeTabs.events, selectedTab: $selectedTab, onTapAction: onTapAction) 125 | 126 | CustomTabBarItem(iconName: "calendar", title: String(localized: "Calendars", comment: "Label for Calendars tab in the tab bar and section header for calendars in events view."), tab: HomeTabs.calendars, selectedTab: $selectedTab, onTapAction: onTapAction) 127 | } 128 | .frame(height: 50) 129 | .background(Color.gray.opacity(0.2)) 130 | } 131 | } 132 | 133 | struct CustomTabBarItem: View { 134 | let iconName: String 135 | let title: String 136 | let tab: HomeTabs 137 | @Binding var selectedTab: HomeTabs 138 | 139 | let onTapAction: () -> Void 140 | 141 | var body: some View { 142 | VStack { 143 | Image(systemName: iconName) 144 | .resizable() 145 | .scaledToFill() 146 | .frame(width: 20, height: 20) 147 | Text(title) 148 | .font(.caption) 149 | } 150 | .padding() 151 | .contentShape(Rectangle()) 152 | .onTapGesture { 153 | selectedTab = tab 154 | onTapAction() 155 | } 156 | .foregroundColor(selectedTab == tab ? .accent : .gray) 157 | .frame(maxWidth: .infinity) 158 | } 159 | } 160 | 161 | //struct ContentView_Previews: PreviewProvider { 162 | // 163 | // @State static var appState = AppState() 164 | // 165 | // static var previews: some View { 166 | // ContentView() 167 | // .environmentObject(appState) 168 | // .modelContainer(for: [AppSettings.self]) 169 | // } 170 | //} 171 | -------------------------------------------------------------------------------- /Comingle/Views/SignInView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 6/18/23. 6 | // 7 | 8 | import Combine 9 | import NostrSDK 10 | import SwiftData 11 | import SwiftUI 12 | 13 | struct SignInView: View, RelayURLValidating { 14 | @Environment(\.dismiss) private var dismiss 15 | 16 | @EnvironmentObject var appState: AppState 17 | 18 | @State private var nostrIdentifier: String = "" 19 | @State private var primaryRelay: String = "" 20 | 21 | @State private var validKey: Bool = false 22 | @State private var validatedRelayURL: URL? 23 | 24 | @State private var keypair: Keypair? 25 | @State private var publicKey: PublicKey? 26 | 27 | private func relayFooter() -> AttributedString { 28 | var footer = AttributedString(localized: "Try \(AppState.defaultRelayURLString). Note: authenticated relays are not yet supported.", comment: "Text prompting user to try connecting to the default relay and a note mentioning that authenticated relays are not yet supported.") 29 | if let range = footer.range(of: AppState.defaultRelayURLString) { 30 | footer[range].underlineStyle = .single 31 | footer[range].foregroundColor = .accent 32 | } 33 | 34 | return footer 35 | } 36 | 37 | private func isValidRelay(address: String) -> Bool { 38 | (try? validateRelayURLString(address)) != nil 39 | } 40 | 41 | @MainActor 42 | private func signIn() { 43 | guard let validatedRelayURL else { 44 | return 45 | } 46 | 47 | if let keypair { 48 | appState.signIn(keypair: keypair, relayURLs: [validatedRelayURL]) 49 | dismiss() 50 | } else if let publicKey { 51 | appState.signIn(publicKey: publicKey, relayURLs: [validatedRelayURL]) 52 | dismiss() 53 | } 54 | } 55 | 56 | var body: some View { 57 | NavigationStack { 58 | Form { 59 | Section( 60 | content: { 61 | TextField(String(localized: "wss://relay.example.com", comment: "Example URL of a Nostr relay address."), text: $primaryRelay) 62 | .autocorrectionDisabled(false) 63 | .textContentType(.URL) 64 | .textInputAutocapitalization(.never) 65 | .autocorrectionDisabled() 66 | .onReceive(Just(primaryRelay)) { newValue in 67 | let filtered = newValue.trimmingCharacters(in: .whitespacesAndNewlines) 68 | primaryRelay = filtered 69 | 70 | if filtered.isEmpty { 71 | return 72 | } 73 | 74 | validatedRelayURL = try? validateRelayURLString(filtered) 75 | } 76 | }, 77 | header: { 78 | Text("Primary Nostr Relay (Required)", comment: "Header text prompting required entry of the primary Nostr relay.") 79 | }, 80 | footer: { 81 | Text(relayFooter()) 82 | .onTapGesture { 83 | primaryRelay = AppState.defaultRelayURLString 84 | } 85 | } 86 | ) 87 | 88 | Section( 89 | content: { 90 | SecureField(String(localized: "Enter a Nostr public key or private key", comment: "Prompt asking user to enter in a Nostr key."), text: $nostrIdentifier) 91 | .autocorrectionDisabled(false) 92 | .textContentType(.password) 93 | .textInputAutocapitalization(.never) 94 | .onReceive(Just(nostrIdentifier)) { newValue in 95 | let filtered = newValue.trimmingCharacters(in: .whitespacesAndNewlines) 96 | nostrIdentifier = filtered 97 | 98 | if let keypair = Keypair(nsec: filtered) { 99 | self.keypair = keypair 100 | self.publicKey = keypair.publicKey 101 | validKey = true 102 | } else if let publicKey = PublicKey(npub: filtered) { 103 | self.keypair = nil 104 | self.publicKey = publicKey 105 | validKey = true 106 | } else { 107 | self.keypair = nil 108 | self.publicKey = nil 109 | validKey = false 110 | } 111 | } 112 | }, 113 | header: { 114 | Text("Nostr Key", comment: "Header text prompting optional entry of the user's private or public key.") 115 | }, 116 | footer: { 117 | if keypair != nil { 118 | Text("You have entered a private key, which means you will be able to view, create, modify, and RSVP to events.", comment: "Footer text indicating what it means to have a private key entered.") 119 | } else if publicKey != nil { 120 | Text("You have entered a public key, which means you will be able to only view events.", comment: "Footer text indicating what it means to use a public key.") 121 | } 122 | } 123 | ) 124 | } 125 | 126 | Button(String(localized: "Find Me on Nostr", comment: "Button to query data using the private or public key on Nostr relays.")) { 127 | signIn() 128 | } 129 | .buttonStyle(.borderedProminent) 130 | .disabled(!validKey || validatedRelayURL == nil) 131 | } 132 | .onAppear { 133 | let credentialHandler = CredentialHandler(appState: appState) 134 | credentialHandler.checkCredentials() 135 | } 136 | } 137 | } 138 | 139 | //struct SignInView_Previews: PreviewProvider { 140 | // 141 | // @State static var appState = AppState() 142 | // 143 | // static var previews: some View { 144 | // SignInView() 145 | // .environmentObject(appState) 146 | // } 147 | //} 148 | -------------------------------------------------------------------------------- /Comingle/Views/CreateOrModifyCalendarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateOrModifyCalendarView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 8/16/24. 6 | // 7 | 8 | import Kingfisher 9 | import NostrSDK 10 | import OrderedCollections 11 | import SwiftUI 12 | 13 | struct CreateOrModifyCalendarView: View { 14 | @Environment(\.dismiss) private var dismiss 15 | 16 | @State private var viewModel: ViewModel 17 | 18 | init(appState: AppState, calendarListEvent: CalendarListEvent? = nil) { 19 | let viewModel = ViewModel(appState: appState, existingCalendarListEvent: calendarListEvent) 20 | _viewModel = State(initialValue: viewModel) 21 | } 22 | 23 | var body: some View { 24 | if viewModel.appState.keypair != nil && (viewModel.existingCalendarListEvent == nil || viewModel.appState.publicKey?.hex == viewModel.existingCalendarListEvent?.pubkey) { 25 | Form { 26 | let title = String(localized: "Title", comment: "Text indicating that the field is for entering a calendar title.") 27 | Section { 28 | TextField(title, text: $viewModel.title) 29 | } header: { 30 | Text(title) 31 | } 32 | 33 | Section { 34 | TextEditor(text: $viewModel.description) 35 | } header: { 36 | Text("Calendar Description", comment: "Section title for calendar description.") 37 | } 38 | 39 | Section { 40 | TextField(String(localized: "https://example.com/image.png", comment: "Example image URL of a calendar event image."), text: $viewModel.imageString) 41 | .textContentType(.URL) 42 | .autocorrectionDisabled() 43 | .textInputAutocapitalization(.never) 44 | 45 | if let validatedImageURL = viewModel.validatedImageURL { 46 | KFImage.url(validatedImageURL) 47 | .resizable() 48 | .placeholder { ProgressView() } 49 | .scaledToFit() 50 | .frame(maxWidth: 100, maxHeight: 200) 51 | } 52 | } header: { 53 | Text("Image", comment: "Section title for image of the event.") 54 | } 55 | } 56 | .navigationTitle(viewModel.navigationTitle) 57 | .toolbar { 58 | ToolbarItem(placement: .primaryAction) { 59 | Button(action: { 60 | if viewModel.saveCalendarListEvent() { 61 | dismiss() 62 | } 63 | }, label: { 64 | Text("Save", comment: "Button to save a form.") 65 | }) 66 | .disabled(!viewModel.canSave) 67 | } 68 | } 69 | } else { 70 | // This view should not be used unless the user is signed in with a private key. 71 | // Therefore, this EmptyView technically should never be shown. 72 | EmptyView() 73 | } 74 | } 75 | } 76 | 77 | extension CreateOrModifyCalendarView { 78 | @Observable class ViewModel: EventCreating { 79 | let appState: AppState 80 | 81 | let existingCalendarListEvent: CalendarListEvent? 82 | 83 | var title: String = "" 84 | var description: String = "" 85 | var imageString: String = "" 86 | var timeBasedCalendarEventsCoordinates = OrderedSet() 87 | 88 | init(appState: AppState, existingCalendarListEvent: CalendarListEvent?) { 89 | self.appState = appState 90 | self.existingCalendarListEvent = existingCalendarListEvent 91 | reset() 92 | } 93 | 94 | func reset() { 95 | title = existingCalendarListEvent?.title ?? "" 96 | description = existingCalendarListEvent?.content ?? "" 97 | imageString = existingCalendarListEvent?.imageURL?.absoluteString ?? "" 98 | timeBasedCalendarEventsCoordinates = OrderedSet(existingCalendarListEvent?.calendarEventCoordinateList ?? []) 99 | } 100 | 101 | var trimmedTitle: String { 102 | title.trimmingCharacters(in: .whitespacesAndNewlines) 103 | } 104 | 105 | var trimmedDescription: String { 106 | description.trimmingCharacters(in: .whitespacesAndNewlines) 107 | } 108 | 109 | var trimmedImageString: String { 110 | imageString.trimmingCharacters(in: .whitespacesAndNewlines) 111 | } 112 | 113 | var validatedImageURL: URL? { 114 | guard let url = URL(string: trimmedImageString) else { 115 | return nil 116 | } 117 | 118 | return url 119 | } 120 | 121 | var navigationTitle: String { 122 | if existingCalendarListEvent != nil { 123 | String(localized: "Modify Calendar", comment: "Button to modify calendar.") 124 | } else { 125 | String(localized: "Create a Calendar", comment: "Navigation title for the view to create a calendar.") 126 | } 127 | } 128 | 129 | var canSave: Bool { 130 | appState.keypair != nil && !trimmedTitle.isEmpty && (trimmedImageString.isEmpty || validatedImageURL != nil) 131 | } 132 | 133 | func saveCalendarListEvent() -> Bool { 134 | guard let keypair = appState.keypair else { 135 | return false 136 | } 137 | 138 | do { 139 | let calendarListEvent = try calendarListEvent( 140 | withIdentifier: existingCalendarListEvent?.identifier ?? UUID().uuidString, 141 | title: trimmedTitle, 142 | description: trimmedDescription, 143 | calendarEventsCoordinates: existingCalendarListEvent?.calendarEventCoordinateList ?? [], 144 | imageURL: validatedImageURL, 145 | signedBy: keypair 146 | ) 147 | 148 | if let calendarListEventCoordinates = calendarListEvent.replaceableEventCoordinates()?.tag.value { 149 | appState.calendarListEvents[calendarListEventCoordinates] = calendarListEvent 150 | 151 | let persistentNostrEvent = PersistentNostrEvent(nostrEvent: calendarListEvent) 152 | appState.modelContext.insert(persistentNostrEvent) 153 | try appState.modelContext.save() 154 | 155 | appState.updateCalendarsTrie(oldCalendar: existingCalendarListEvent, newCalendar: calendarListEvent) 156 | 157 | appState.relayWritePool.publishEvent(calendarListEvent) 158 | 159 | return true 160 | } 161 | } catch { 162 | print("Unable to save time based calendar event. \(error)") 163 | } 164 | 165 | return false 166 | } 167 | } 168 | } 169 | 170 | //#Preview { 171 | // CreateCalendarView() 172 | //} 173 | -------------------------------------------------------------------------------- /Comingle/Views/Settings/RelaysSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RelaysSettingsView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/10/24. 6 | // 7 | 8 | import Combine 9 | import NostrSDK 10 | import SwiftData 11 | import SwiftUI 12 | 13 | struct RelaysSettingsView: View, RelayURLValidating { 14 | @EnvironmentObject var appState: AppState 15 | 16 | @State private var validatedRelayURL: URL? 17 | @State private var newRelay: String = "" 18 | 19 | var body: some View { 20 | List { 21 | Section( 22 | content: { 23 | if let relayPoolSettings = appState.relayPoolSettings { 24 | ForEach(relayPoolSettings.relaySettingsList, id: \.self) { relaySettings in 25 | HStack { 26 | let relayMarkerBinding = Binding( 27 | get: { 28 | switch (relaySettings.read, relaySettings.write) { 29 | case (true, true): 30 | .readAndWrite 31 | case (true, false): 32 | .read 33 | case (false, true): 34 | .write 35 | default: 36 | .read 37 | } 38 | }, 39 | set: { 40 | switch $0 { 41 | case .readAndWrite: 42 | relaySettings.read = true 43 | relaySettings.write = true 44 | case .read: 45 | relaySettings.read = true 46 | relaySettings.write = false 47 | case .write: 48 | relaySettings.read = false 49 | relaySettings.write = true 50 | } 51 | } 52 | ) 53 | switch appState.relayState(relayURLString: relaySettings.relayURLString) { 54 | case .connected: 55 | Image(systemName: "checkmark.circle") 56 | .foregroundStyle(.green) 57 | case .connecting: 58 | Image(systemName: "hourglass.circle") 59 | .foregroundStyle(.yellow) 60 | case .error: 61 | Image(systemName: "x.circle.fill") 62 | .foregroundStyle(.red) 63 | case .notConnected, .none: 64 | Image(systemName: "pause.circle") 65 | .foregroundStyle(.red) 66 | } 67 | Picker(relaySettings.relayURLString, selection: relayMarkerBinding) { 68 | ForEach(RelayOption.allCases, id: \.self) { option in 69 | Text(option.localizedString) 70 | } 71 | } 72 | .pickerStyle(.navigationLink) 73 | .swipeActions { 74 | Button(role: .destructive) { 75 | appState.removeRelaySettings(relaySettings: relaySettings) 76 | } label: { 77 | Label(String(localized: "Delete", comment: "Label indicating button will delete item."), systemImage: "trash") 78 | } 79 | } 80 | } 81 | } 82 | 83 | HStack { 84 | TextField(String(localized: "wss://relay.example.com", comment: "Example URL of a Nostr relay address."), text: $newRelay) 85 | .textContentType(.URL) 86 | .textInputAutocapitalization(.never) 87 | .autocorrectionDisabled() 88 | .onReceive(Just(newRelay)) { newValue in 89 | let filtered = newValue.trimmingCharacters(in: .whitespacesAndNewlines) 90 | newRelay = filtered 91 | 92 | if filtered.isEmpty { 93 | return 94 | } 95 | 96 | validatedRelayURL = try? validateRelayURLString(filtered) 97 | } 98 | 99 | Button( 100 | action: { 101 | if let validatedRelayURL, canAddRelay { 102 | appState.addRelay(relayURL: validatedRelayURL) 103 | newRelay = "" 104 | } 105 | }, 106 | label: { 107 | Image(systemName: "plus.circle") 108 | } 109 | ) 110 | .disabled(!canAddRelay) 111 | } 112 | } 113 | }, 114 | header: { 115 | Text("Relays", comment: "Settings section for relay management.") 116 | }, 117 | footer: { 118 | Text("Relay settings are saved locally to this device. Authenticated relays and publishing relay lists are not yet supported.", comment: "Relay settings footer text explaining where relay settings are stored and the limitations of relay connections.") 119 | } 120 | ) 121 | } 122 | } 123 | 124 | var canAddRelay: Bool { 125 | guard let validatedRelayURL, let relaySettingsList = appState.appSettings?.activeProfile?.profileSettings?.relayPoolSettings?.relaySettingsList, !relaySettingsList.contains(where: { $0.relayURLString == validatedRelayURL.absoluteString }) else { 126 | return false 127 | } 128 | return true 129 | } 130 | } 131 | 132 | enum RelayOption: CaseIterable { 133 | case read 134 | case write 135 | case readAndWrite 136 | 137 | var localizedString: String { 138 | switch self { 139 | case .read: 140 | String(localized: "Read", comment: "Picker label to specify preference of only reading from a relay.") 141 | case .write: 142 | String(localized: "Write", comment: "Picker label to specify preference of only writing to a relay.") 143 | case .readAndWrite: 144 | String(localized: "Read and Write", comment: "Picker label to specify preference of reading from and writing to a relay.") 145 | } 146 | } 147 | } 148 | 149 | //#Preview { 150 | // let modelConfiguration = ModelConfiguration(isStoredInMemoryOnly: true) 151 | // 152 | // guard let modelContainer = try? ModelContainer(for: Profile.self, configurations: modelConfiguration), let publicKey = Keypair() else { 153 | // EmptyView() 154 | // } 155 | // 156 | // RelaysSettingsView(modelContext: modelContainer.mainContext, publicKeyHex: publicKey.publicKey.hex) 157 | //} 158 | -------------------------------------------------------------------------------- /Comingle/Views/CreateProfileView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateProfileView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 8/4/24. 6 | // 7 | 8 | import Kingfisher 9 | import NostrSDK 10 | import OrderedCollections 11 | import SwiftUI 12 | 13 | struct CreateProfileView: View, EventCreating { 14 | @Environment(\.dismiss) private var dismiss 15 | 16 | @EnvironmentObject var appState: AppState 17 | 18 | @State private var credentialHandler: CredentialHandler 19 | 20 | @State private var keypair: Keypair = Keypair.init()! 21 | 22 | @State private var username: String = "" 23 | @State private var about: String = "" 24 | @State private var picture: String = "" 25 | @State private var displayName: String = "" 26 | 27 | @State private var hasCopiedPublicKey: Bool = false 28 | @State private var hasCopiedPrivateKey: Bool = false 29 | 30 | init(appState: AppState) { 31 | credentialHandler = CredentialHandler(appState: appState) 32 | } 33 | 34 | var validatedPictureURL: URL? { 35 | guard let url = URL(string: picture.trimmingCharacters(in: .whitespacesAndNewlines)) else { 36 | return nil 37 | } 38 | 39 | return url 40 | } 41 | 42 | var canSave: Bool { 43 | return username.trimmedOrNilIfEmpty != nil && (picture.trimmedOrNilIfEmpty == nil || validatedPictureURL != nil) 44 | } 45 | 46 | var body: some View { 47 | Form { 48 | Section { 49 | Button(action: { 50 | UIPasteboard.general.string = keypair.publicKey.npub 51 | hasCopiedPublicKey = true 52 | }, label: { 53 | HStack { 54 | Text(keypair.publicKey.npub) 55 | .textContentType(.username) 56 | .disabled(true) 57 | .lineLimit(2) 58 | .minimumScaleFactor(0.1) 59 | 60 | if hasCopiedPublicKey { 61 | Image(systemName: "doc.on.doc.fill") 62 | } else { 63 | Image(systemName: "doc.on.doc") 64 | } 65 | } 66 | }) 67 | } header: { 68 | Text("Public Key", comment: "Section header for public key.") 69 | } footer: { 70 | Text("This public key is your unique identifier. You can share it with other people to identify you across any Nostr app. Save it in a place you will remember to look.", comment: "Footer text to explain what is the created public key.") 71 | } 72 | 73 | Section { 74 | Button(action: { 75 | UIPasteboard.general.string = keypair.privateKey.nsec 76 | hasCopiedPrivateKey = true 77 | }, label: { 78 | HStack { 79 | Text(keypair.privateKey.nsec) 80 | .textContentType(.newPassword) 81 | .disabled(true) 82 | .lineLimit(2) 83 | .minimumScaleFactor(0.1) 84 | 85 | if hasCopiedPrivateKey { 86 | Image(systemName: "doc.on.doc.fill") 87 | } else { 88 | Image(systemName: "doc.on.doc") 89 | } 90 | } 91 | }) 92 | } header: { 93 | Text("Private Key", comment: "Section header for private key.") 94 | } footer: { 95 | Text("This private key should not be shared with anyone. You can use it to sign into any Nostr app. Keep it secure in a password manager. You will not be able to recover it after you leave this screen.", comment: "Footer text to explain what is the created private key.") 96 | } 97 | 98 | let usernameTitle = String(localized: "Username", comment: "Section title for username entry.") 99 | Section { 100 | TextField(usernameTitle, text: $username) 101 | .autocorrectionDisabled() 102 | .textInputAutocapitalization(.never) 103 | } header: { 104 | Text(usernameTitle) 105 | } footer: { 106 | Text("Usernames are not unique and not used for signing into an account. More than one person can have the same username.", comment: "Footer text to explain usernames.") 107 | } 108 | 109 | let displayNameTitle = String(localized: "Display Name (Optional)", comment: "Section title for display name entry.") 110 | Section { 111 | TextField(displayNameTitle, text: $displayName) 112 | .autocorrectionDisabled() 113 | .textInputAutocapitalization(.never) 114 | } header: { 115 | Text(displayNameTitle) 116 | } footer: { 117 | Text("An alternative, bigger name with richer characters than username.", comment: "Footer text to explain what is the display name.") 118 | } 119 | 120 | Section { 121 | TextField(String(localized: "https://example.com/image.png", comment: "Example image URL of a calendar event image."), text: $picture) 122 | .textContentType(.URL) 123 | .autocorrectionDisabled() 124 | .textInputAutocapitalization(.never) 125 | 126 | if let validatedPictureURL { 127 | KFImage.url(validatedPictureURL) 128 | .resizable() 129 | .placeholder { ProgressView() } 130 | .scaledToFit() 131 | .frame(maxWidth: 100, maxHeight: 200) 132 | } 133 | } header: { 134 | Text("Profile Picture (Optional)", comment: "Section title for profile picture entry.") 135 | } 136 | } 137 | .toolbar { 138 | ToolbarItem(placement: .primaryAction) { 139 | Button(action: { 140 | credentialHandler.saveCredential(keypair: keypair) 141 | appState.privateKeySecureStorage.store(for: keypair) 142 | let userMetadata = UserMetadata(name: username.trimmedOrNilIfEmpty, displayName: displayName.trimmedOrNilIfEmpty, pictureURL: validatedPictureURL) 143 | 144 | do { 145 | let readRelayURLs = appState.relayReadPool.relays.map { $0.url } 146 | let writeRelayURLs = appState.relayWritePool.relays.map { $0.url } 147 | 148 | let metadataEvent = try metadataEvent(withUserMetadata: userMetadata, signedBy: keypair) 149 | let followListEvent = try followList(withPubkeys: [keypair.publicKey.hex], signedBy: keypair) 150 | appState.relayWritePool.publishEvent(metadataEvent) 151 | appState.relayWritePool.publishEvent(followListEvent) 152 | 153 | let persistentNostrEvents = [ 154 | PersistentNostrEvent(nostrEvent: metadataEvent), 155 | PersistentNostrEvent(nostrEvent: followListEvent) 156 | ] 157 | persistentNostrEvents.forEach { 158 | appState.modelContext.insert($0) 159 | } 160 | 161 | try appState.modelContext.save() 162 | 163 | appState.loadPersistentNostrEvents(persistentNostrEvents) 164 | 165 | appState.signIn(keypair: keypair, relayURLs: Array(Set(readRelayURLs + writeRelayURLs))) 166 | } catch { 167 | print("Unable to publish or save MetadataEvent for new profile \(keypair.publicKey.npub).") 168 | } 169 | 170 | dismiss() 171 | }, label: { 172 | Text("Create Profile", comment: "Button to create a profile.") 173 | }) 174 | .disabled(!canSave) 175 | } 176 | } 177 | } 178 | } 179 | 180 | //#Preview { 181 | // CreateProfileView() 182 | //} 183 | -------------------------------------------------------------------------------- /Comingle/Views/Settings/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 6/18/23. 6 | // 7 | 8 | import NostrSDK 9 | import SwiftData 10 | import SwiftUI 11 | 12 | struct SettingsView: View { 13 | 14 | @State private var viewModel: ViewModel 15 | 16 | @State private var profilePickerExpanded: Bool = false 17 | 18 | @State private var profileToSignOut: Profile? 19 | @State private var isShowingSignOutConfirmation: Bool = false 20 | @State private var isShowingAddProfileConfirmation: Bool = false 21 | 22 | init(appState: AppState) { 23 | let viewModel = ViewModel(appState: appState) 24 | _viewModel = State(initialValue: viewModel) 25 | } 26 | 27 | var profilesSection: some View { 28 | Section( 29 | content: { 30 | DisclosureGroup( 31 | isExpanded: $profilePickerExpanded, 32 | content: { 33 | ForEach(viewModel.profiles, id: \.self) { profile in 34 | HStack { 35 | if viewModel.isSignedInWithPrivateKey(profile) { 36 | ProfilePictureView(publicKeyHex: profile.publicKeyHex) 37 | } else { 38 | ImageOverlayView(imageSystemName: "lock.fill", overlayBackgroundColor: .accent) { 39 | ProfilePictureView(publicKeyHex: profile.publicKeyHex) 40 | } 41 | } 42 | if viewModel.isActiveProfile(profile) { 43 | ProfileNameView(publicKeyHex: profile.publicKeyHex) 44 | .foregroundStyle(.accent) 45 | } else { 46 | ProfileNameView(publicKeyHex: profile.publicKeyHex) 47 | } 48 | } 49 | .tag(profile.publicKeyHex) 50 | .onTapGesture { 51 | viewModel.appState.updateActiveProfile(profile) 52 | profilePickerExpanded = false 53 | } 54 | .swipeActions { 55 | if profile.publicKeyHex != nil { 56 | Button(role: .destructive) { 57 | profileToSignOut = profile 58 | isShowingSignOutConfirmation = true 59 | } label: { 60 | Label(String(localized: "Sign Out", comment: "Label indicating that the button signs out of a profile."), systemImage: "door.left.hand.open") 61 | } 62 | } 63 | } 64 | } 65 | 66 | Button(action: { 67 | isShowingAddProfileConfirmation = true 68 | }, label: { 69 | HStack { 70 | Image(systemName: "plus.circle") 71 | .resizable() 72 | .scaledToFit() 73 | .frame(width: 40) 74 | Text("Add Profile", comment: "Button to add a profile.") 75 | } 76 | }) 77 | }, 78 | label: { 79 | let publicKeyHex = viewModel.publicKeyHex 80 | if let publicKeyHex, PublicKey(hex: publicKeyHex) != nil { 81 | if viewModel.isActiveProfileSignedInWithPrivateKey { 82 | ProfilePictureView(publicKeyHex: publicKeyHex) 83 | } else { 84 | ImageOverlayView(imageSystemName: "lock.fill", overlayBackgroundColor: .accent) { 85 | ProfilePictureView(publicKeyHex: publicKeyHex) 86 | } 87 | } 88 | } else { 89 | ImageOverlayView(imageSystemName: "lock.fill", overlayBackgroundColor: .accent) { 90 | GuestProfilePictureView() 91 | } 92 | } 93 | ProfileNameView(publicKeyHex: publicKeyHex) 94 | } 95 | ) 96 | 97 | if let publicKeyHex = viewModel.publicKeyHex, PublicKey(hex: publicKeyHex) != nil { 98 | NavigationLink(destination: ProfileView(publicKeyHex: publicKeyHex)) { 99 | Text("View Profile", comment: "Button to view the active profile.") 100 | } 101 | } 102 | }, 103 | header: { 104 | Text("Profiles", comment: "Section title for Profiles in the settings view.") 105 | } 106 | ) 107 | } 108 | 109 | var profileSettingsSection: some View { 110 | Section( 111 | content: { 112 | let publicKeyHex = viewModel.publicKeyHex 113 | if let publicKeyHex, let publicKey = PublicKey(hex: publicKeyHex) { 114 | NavigationLink(destination: KeysSettingsView(publicKey: publicKey)) { 115 | Label(String(localized: "Keys", comment: "Settings section for Nostr key management."), systemImage: "key") 116 | } 117 | } 118 | NavigationLink(destination: RelaysSettingsView()) { 119 | Label(String(localized: "Relays", comment: "Settings section for relay management."), systemImage: "server.rack") 120 | } 121 | NavigationLink(destination: AppearanceSettingsView(modelContext: viewModel.appState.modelContext, publicKeyHex: viewModel.publicKeyHex)) { 122 | Label(String(localized: "Appearance", comment: "Settings section for appearance of the app."), systemImage: "eye") 123 | } 124 | }, 125 | header: { 126 | Text("Settings for \(viewModel.activeProfileName)", comment: "Section title for settings for profile") 127 | } 128 | ) 129 | } 130 | 131 | var aboutSection: some View { 132 | Section( 133 | content: { 134 | LabeledContent(String(format: String(localized: "Version", comment: "Label for the app version in the settings view.")), value: viewModel.appVersion) 135 | 136 | NavigationLink(destination: AcknowledgementsView()) { 137 | Text("Acknowledgements", comment: "View for seeing the acknowledgements of projects that this app depends on.") 138 | } 139 | 140 | if let comingleProfileURL = Utilities.shared.externalNostrProfileURL(npub: "npub1c0nfstrlj0jy8kvl953v84hudwnpgad0zx709z0ey7nmjp0llegslzg243") { 141 | Button(action: { 142 | UIApplication.shared.open(comingleProfileURL) 143 | }, label: { 144 | Text("Comingle Profile", comment: "Button to open the Nostr profile of the Comingle account.") 145 | }) 146 | } 147 | 148 | if let url = URL(string: "https://github.com/comingle-co/comingle-ios/issues") { 149 | Button(action: { 150 | UIApplication.shared.open(url) 151 | }, label: { 152 | Text("Report an Issue", comment: "Button to report an issue about the app.") 153 | }) 154 | } 155 | }, 156 | header: { 157 | Text("About", comment: "Settings about section title.") 158 | } 159 | ) 160 | } 161 | 162 | var body: some View { 163 | NavigationStack { 164 | Form { 165 | profilesSection 166 | 167 | profileSettingsSection 168 | 169 | aboutSection 170 | 171 | if let activeProfile = viewModel.activeProfile, activeProfile.publicKeyHex != nil { 172 | Section { 173 | Button( 174 | action: { 175 | profileToSignOut = activeProfile 176 | isShowingSignOutConfirmation = true 177 | }, 178 | label: { 179 | Label( 180 | String(localized: "Sign Out of \(viewModel.activeProfileName)", comment: "Button to sign out of a profile from the device."), 181 | systemImage: "door.left.hand.open" 182 | ) 183 | } 184 | ) 185 | } 186 | } 187 | } 188 | } 189 | .navigationTitle(String(localized: "Settings", comment: "Navigation title for the settings view.")) 190 | .sheet(isPresented: $viewModel.isSignInViewPresented) { 191 | NavigationStack { 192 | SignInView() 193 | } 194 | .presentationDetents([.medium]) 195 | .presentationDragIndicator(.visible) 196 | } 197 | .confirmationDialog( 198 | Text("Add Profile", comment: "Button to add a profile."), 199 | isPresented: $isShowingAddProfileConfirmation 200 | ) { 201 | NavigationLink(destination: CreateProfileView(appState: viewModel.appState)) { 202 | Text("Create Profile", comment: "Button to create a profile.") 203 | } 204 | 205 | Button(action: { 206 | viewModel.isSignInViewPresented = true 207 | }, label: { 208 | Text("Sign Into Existing Profile", comment: "Button to sign into existing profile.") 209 | }) 210 | } 211 | .confirmationDialog( 212 | Text("Sign out of profile?", comment: "Title of confirmation dialog when user initiates a profile sign out."), 213 | isPresented: $isShowingSignOutConfirmation 214 | ) { 215 | if let profileToSignOut, let publicKeyHex = profileToSignOut.publicKeyHex { 216 | Button(role: .destructive) { 217 | viewModel.signOut(profileToSignOut) 218 | self.profileToSignOut = nil 219 | } label: { 220 | Text("Sign Out of \(viewModel.profileName(publicKeyHex: publicKeyHex))", comment: "Button to sign out of a profile from the device.") 221 | } 222 | } 223 | 224 | Button(role: .cancel) { 225 | profileToSignOut = nil 226 | } label: { 227 | Text("Cancel", comment: "Button to cancel out of dialog.") 228 | } 229 | } message: { 230 | Text("Your app settings will be deleted from this device. Your data on Nostr relays will not be affected.", comment: "Message to inform user about what will happen if they sign out.") 231 | } 232 | } 233 | } 234 | 235 | extension SettingsView { 236 | @Observable class ViewModel { 237 | let appState: AppState 238 | var profilePickerExpanded: Bool = false 239 | var isSignInViewPresented: Bool = false 240 | 241 | init(appState: AppState) { 242 | self.appState = appState 243 | } 244 | 245 | var publicKeyHex: String? { 246 | appState.appSettings?.activeProfile?.publicKeyHex 247 | } 248 | 249 | var activeProfile: Profile? { 250 | appState.appSettings?.activeProfile 251 | } 252 | 253 | var activeProfileName: String { 254 | profileName(publicKeyHex: publicKeyHex) 255 | } 256 | 257 | var profiles: [Profile] { 258 | appState.profiles 259 | } 260 | 261 | func profileName(publicKeyHex: String?) -> String { 262 | Utilities.shared.profileName( 263 | publicKeyHex: publicKeyHex, 264 | appState: appState 265 | ) 266 | } 267 | 268 | var isActiveProfileSignedInWithPrivateKey: Bool { 269 | guard let activeProfile = appState.appSettings?.activeProfile else { 270 | return false 271 | } 272 | return isSignedInWithPrivateKey(activeProfile) 273 | } 274 | 275 | func isSignedInWithPrivateKey(_ profile: Profile) -> Bool { 276 | guard let publicKeyHex = profile.publicKeyHex, let publicKey = PublicKey(hex: publicKeyHex) else { 277 | return false 278 | } 279 | return PrivateKeySecureStorage.shared.keypair(for: publicKey) != nil 280 | } 281 | 282 | func signOut(_ profile: Profile) { 283 | appState.deleteProfile(profile) 284 | } 285 | 286 | func isActiveProfile(_ profile: Profile) -> Bool { 287 | return appState.appSettings?.activeProfile == profile 288 | } 289 | 290 | var appVersion: String { 291 | guard let shortVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"], 292 | let bundleVersion = Bundle.main.infoDictionary?["CFBundleVersion"] 293 | else { 294 | return String(localized: "Unknown", comment: "Text indicating that the version of the app that is running is unknown.") 295 | } 296 | 297 | return String(localized: "\(String(describing: shortVersion)) (\(String(describing: bundleVersion)))", comment: "Text indicating the version of the app that is running. The first argument is the version number, and the second argument is the build number.") 298 | } 299 | } 300 | } 301 | 302 | //struct SettingsView_Previews: PreviewProvider { 303 | // 304 | // @State static var appState = AppState() 305 | // 306 | // static var previews: some View { 307 | // SettingsView() 308 | // .environmentObject(appState) 309 | // } 310 | //} 311 | -------------------------------------------------------------------------------- /Comingle/Assets/Localization/Localizable.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "%@ (%@)" : { 5 | "comment" : "Text indicating the version of the app that is running. The first argument is the version number, and the second argument is the build number.", 6 | "localizations" : { 7 | "en" : { 8 | "stringUnit" : { 9 | "state" : "new", 10 | "value" : "%1$@ (%2$@)" 11 | } 12 | } 13 | } 14 | }, 15 | "%lld attended" : { 16 | "comment" : "Number of people who attended the event." 17 | }, 18 | "%lld going" : { 19 | "comment" : "Number of people who are going to the event." 20 | }, 21 | "%lld invited" : { 22 | 23 | }, 24 | "%lld participants" : { 25 | "comment" : "Number of invited participants", 26 | "localizations" : { 27 | "en" : { 28 | "variations" : { 29 | "plural" : { 30 | "one" : { 31 | "stringUnit" : { 32 | "state" : "translated", 33 | "value" : "%lld participant" 34 | } 35 | }, 36 | "other" : { 37 | "stringUnit" : { 38 | "state" : "new", 39 | "value" : "%lld participants" 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | }, 47 | "About" : { 48 | "comment" : "Section title for About section for calendar event description.\nSettings about section title." 49 | }, 50 | "About (Translated)" : { 51 | "comment" : "Section title for About section for calendar event description that has been translated from a non-preferred language to a preferred language." 52 | }, 53 | "Acknowledgements" : { 54 | "comment" : "View for seeing the acknowledgements of projects that this app depends on." 55 | }, 56 | "Add a location" : { 57 | "comment" : "Button to navigate to event location picker sheet." 58 | }, 59 | "Add Location" : { 60 | "comment" : "Button to add location to event." 61 | }, 62 | "Add Profile" : { 63 | "comment" : "Button to add a profile." 64 | }, 65 | "An alternative, bigger name with richer characters than username." : { 66 | "comment" : "Footer text to explain what is the display name." 67 | }, 68 | "Anyone who is not invited can still RSVP to public events. It is up to you to decide if you want to explicitly invite a participant or who can attend the event." : { 69 | "comment" : "Footer text explaining what it means to invite a participant." 70 | }, 71 | "Appearance" : { 72 | "comment" : "Settings section for appearance of the app." 73 | }, 74 | "Attended" : { 75 | "comment" : "Label indicating that the user attended the event." 76 | }, 77 | "Calendar Description" : { 78 | "comment" : "Section title for calendar description." 79 | }, 80 | "Calendars" : { 81 | "comment" : "Calendars\nLabel for Calendars tab in the tab bar and section header for calendars in events view." 82 | }, 83 | "Calendars (%lld)" : { 84 | "comment" : "Section title for Calendars in the event view with the number of calendars in parentheses." 85 | }, 86 | "Cancel" : { 87 | "comment" : "Button to cancel out of dialog." 88 | }, 89 | "Comingle Profile" : { 90 | "comment" : "Button to open the Nostr profile of the Comingle account." 91 | }, 92 | "Copy Calendar ID" : { 93 | "comment" : "Button to copy the ID of the calendar." 94 | }, 95 | "Copy Calendar URL" : { 96 | "comment" : "Button to copy the URL of the calendar." 97 | }, 98 | "Copy Coordinates" : { 99 | "comment" : "Button to copy the location coordinates of a calendar event." 100 | }, 101 | "Copy Event Details" : { 102 | "comment" : "Button to copy the details of a calendar event." 103 | }, 104 | "Copy Event ID" : { 105 | "comment" : "Button to copy a calendar event ID." 106 | }, 107 | "Copy Event URL" : { 108 | "comment" : "Button to copy a calendar event URL." 109 | }, 110 | "Copy Location" : { 111 | "comment" : "Button to copy location of calendar event." 112 | }, 113 | "Copy Public Key" : { 114 | "comment" : "Button to copy a user's public key." 115 | }, 116 | "Create a ..." : { 117 | 118 | }, 119 | "Create a Calendar" : { 120 | "comment" : "Navigation title for the view to create a calendar." 121 | }, 122 | "Create an Event" : { 123 | "comment" : "Navigation title for the view to create an event." 124 | }, 125 | "Create Event" : { 126 | 127 | }, 128 | "Create Profile" : { 129 | "comment" : "Button to create a profile." 130 | }, 131 | "Delete" : { 132 | "comment" : "Label indicating button will delete item." 133 | }, 134 | "Did Not Attend" : { 135 | "comment" : "Label indicating that the user did not attend the event." 136 | }, 137 | "Display Name (Optional)" : { 138 | "comment" : "Section title for display name entry." 139 | }, 140 | "Ends" : { 141 | "comment" : "Text indicating that the form field is for setting the event end date and time." 142 | }, 143 | "Enter a Nostr public key or private key" : { 144 | "comment" : "Prompt asking user to enter in a Nostr key." 145 | }, 146 | "Enter a time zone if this is primarily an in-person event." : { 147 | "comment" : "Footer text to explain when a time zone should be entered." 148 | }, 149 | "Event" : { 150 | "comment" : "Picker option settings for using the event time zone if it exists." 151 | }, 152 | "Event Description" : { 153 | "comment" : "Section title for event description." 154 | }, 155 | "Event not found. Go back to the previous screen." : { 156 | "comment" : "Text indicating that the event could not be found." 157 | }, 158 | "Find Me on Nostr" : { 159 | "comment" : "Button to query data using the private or public key on Nostr relays." 160 | }, 161 | "Going" : { 162 | "comment" : "Text to indicate that the current user is going to the event." 163 | }, 164 | "Guest" : { 165 | "comment" : "Name of Guest account that is not signed in." 166 | }, 167 | "Hello, World!" : { 168 | 169 | }, 170 | "Home" : { 171 | "comment" : "Tab label for the home view." 172 | }, 173 | "https://example.com/image.png" : { 174 | "comment" : "Example image URL of a calendar event image." 175 | }, 176 | "Image" : { 177 | "comment" : "Section title for image of the event." 178 | }, 179 | "Invited (%lld)" : { 180 | "comment" : "Text for section for invited participants to a calendar event and the number of invited in parentheses." 181 | }, 182 | "Keys" : { 183 | "comment" : "Settings section for Nostr key management." 184 | }, 185 | "Last Updated" : { 186 | "comment" : "Section title for event last updated date." 187 | }, 188 | "Links" : { 189 | "comment" : "Section title for reference links on an event." 190 | }, 191 | "Location" : { 192 | "comment" : "Confirmation dialog for taking action on the location of a calendar event.\nConfirmation dialog title for taking action on the location of a calendar event." 193 | }, 194 | "Maybe Attended" : { 195 | "comment" : "Label indicating that the user maybe attended the event." 196 | }, 197 | "Maybe Going" : { 198 | "comment" : "Text to indicate that the current user might be going to the event." 199 | }, 200 | "Menu" : { 201 | "comment" : "Label for drop down menu in calendar event view." 202 | }, 203 | "Modify Calendar" : { 204 | "comment" : "Button to modify calendar." 205 | }, 206 | "Modify Event" : { 207 | "comment" : "Button to modify event." 208 | }, 209 | "No events found" : { 210 | "comment" : "Text indicating that there are no calendar events." 211 | }, 212 | "No Name" : { 213 | "comment" : "Text to indicate that there is no title for the calendar." 214 | }, 215 | "Nostr Key" : { 216 | "comment" : "Header text prompting optional entry of the user's private or public key." 217 | }, 218 | "Not Going" : { 219 | "comment" : "Text to indicate that the current user is not going to the event." 220 | }, 221 | "nsec..." : { 222 | "comment" : "Placeholder text to prompt user to enter private key." 223 | }, 224 | "OK" : { 225 | "comment" : "Button to acknowledge and dismiss an alert." 226 | }, 227 | "Open in Apple Maps" : { 228 | "comment" : "Button to open a location in Apple Maps." 229 | }, 230 | "Open in Google Maps" : { 231 | "comment" : "Button to open a location in Google Maps." 232 | }, 233 | "Open Link" : { 234 | "comment" : "Button to open link." 235 | }, 236 | "Open Profile in Default Nostr App" : { 237 | "comment" : "Button to open profile in default Nostr app." 238 | }, 239 | "Organizer: %@" : { 240 | "comment" : "Text that indicates who is the event organizer." 241 | }, 242 | "Participants" : { 243 | "comment" : "Section title for participants in event creation view." 244 | }, 245 | "Past" : { 246 | "comment" : "Picker label to filter down past calendar events." 247 | }, 248 | "Primary Nostr Relay (Required)" : { 249 | "comment" : "Header text prompting required entry of the primary Nostr relay." 250 | }, 251 | "Private Key" : { 252 | "comment" : "Section header for private key." 253 | }, 254 | "Private key does not match the public key." : { 255 | "comment" : "Alert message to tell user that the private key that they entered does not match the public key." 256 | }, 257 | "Profile Picture (Optional)" : { 258 | "comment" : "Section title for profile picture entry." 259 | }, 260 | "Profiles" : { 261 | "comment" : "Section title for Profiles in the settings view." 262 | }, 263 | "Profiles (%lld)" : { 264 | "comment" : "Section title for Profiles in the event view with the number of profiles in parentheses." 265 | }, 266 | "Public Key" : { 267 | "comment" : "Section header for public key." 268 | }, 269 | "Read" : { 270 | "comment" : "Picker label to specify preference of only reading from a relay." 271 | }, 272 | "Read and Write" : { 273 | "comment" : "Picker label to specify preference of reading from and writing to a relay." 274 | }, 275 | "Relay settings are saved locally to this device. Authenticated relays and publishing relay lists are not yet supported." : { 276 | "comment" : "Relay settings footer text explaining where relay settings are stored and the limitations of relay connections." 277 | }, 278 | "Relays" : { 279 | "comment" : "Settings section for relay management." 280 | }, 281 | "Relays (%lld)" : { 282 | "comment" : "Text for section for relays a calendar event was found on and the number of relays in parentheses." 283 | }, 284 | "Report an Issue" : { 285 | "comment" : "Button to report an issue about the app." 286 | }, 287 | "Reset All Fields" : { 288 | "comment" : "Button to reset all fields to the starting point of a fresh event creation." 289 | }, 290 | "Restore Original Event" : { 291 | "comment" : "Button to restore event modification fields back to what the original event started with." 292 | }, 293 | "Retract Event" : { 294 | "comment" : "Button to retract an event by requesting to delete it.\nConfirmation dialog title to retract an event by requesting to delete it." 295 | }, 296 | "Retract RSVP" : { 297 | "comment" : "Button to retract the user's existing RSVP." 298 | }, 299 | "Role" : { 300 | "comment" : "Placeholder text for entry of a role of a participant to an event." 301 | }, 302 | "RSVP" : { 303 | "comment" : "Button to RSVP to an event.\nConfirmation dialog title to change RSVP to an event." 304 | }, 305 | "RSVPs (%lld)" : { 306 | "comment" : "Text for section for RSVPs to a calendar event and the number of RSVPs in parentheses." 307 | }, 308 | "Save" : { 309 | "comment" : "Button to save a form." 310 | }, 311 | "Search for calendars" : { 312 | "comment" : "Placeholder text to prompt user to search calendars" 313 | }, 314 | "Search for events or people" : { 315 | "comment" : "Placeholder text to prompt for searching by events or people." 316 | }, 317 | "Search for location" : { 318 | "comment" : "Placeholder text to prompt user to search for a location for the event." 319 | }, 320 | "Search for participant" : { 321 | "comment" : "Placeholder text to prompt user to search for a participant to invite to an event." 322 | }, 323 | "Search for time zone" : { 324 | "comment" : "Placeholder text to prompt user to search for a time zone." 325 | }, 326 | "Set Time Zone" : { 327 | "comment" : "Text for toggle for setting time zone on an event." 328 | }, 329 | "Settings" : { 330 | "comment" : "Navigation title for the settings view." 331 | }, 332 | "Settings for %@" : { 333 | "comment" : "Section title for settings for profile" 334 | }, 335 | "Show Less" : { 336 | "comment" : "Button to hide truncated text." 337 | }, 338 | "Show More" : { 339 | "comment" : "Button to reveal the rest of truncated text." 340 | }, 341 | "Sign In to RSVP" : { 342 | "comment" : "Button to prompt user to sign in so that they can RSVP to an event." 343 | }, 344 | "Sign Into Existing Profile" : { 345 | "comment" : "Button to sign into existing profile." 346 | }, 347 | "Sign Out" : { 348 | "comment" : "Label indicating that the button signs out of a profile." 349 | }, 350 | "Sign Out of %@" : { 351 | "comment" : "Button to sign out of a profile from the device." 352 | }, 353 | "Sign out of profile?" : { 354 | "comment" : "Title of confirmation dialog when user initiates a profile sign out." 355 | }, 356 | "Starts" : { 357 | "comment" : "Text indicating that the form field is for setting the event start date and time." 358 | }, 359 | "Summary" : { 360 | "comment" : "Section title for summary section that summarizes the calendar event." 361 | }, 362 | "System" : { 363 | "comment" : "Picker option settings for using the system time zone." 364 | }, 365 | "Text indicating that the field is for entering an event title." : { 366 | "comment" : "Text indicating that the field is for entering an event title." 367 | }, 368 | "This private key should not be shared with anyone. You can use it to sign into any Nostr app. Keep it secure in a password manager. You will not be able to recover it after you leave this screen." : { 369 | "comment" : "Footer text to explain what is the created private key." 370 | }, 371 | "This public key is your unique identifier. You can share it with other people to identify you across any Nostr app. Save it in a place you will remember to look." : { 372 | "comment" : "Footer text to explain what is the created public key." 373 | }, 374 | "Time" : { 375 | "comment" : "Section title for the event time section." 376 | }, 377 | "Time Zone" : { 378 | "comment" : "Label for time zone setting." 379 | }, 380 | "Title" : { 381 | "comment" : "Text indicating that the field is for entering a calendar title." 382 | }, 383 | "Try %@. Note: authenticated relays are not yet supported." : { 384 | "comment" : "Text prompting user to try connecting to the default relay and a note mentioning that authenticated relays are not yet supported." 385 | }, 386 | "Unknown" : { 387 | "comment" : "Text indicating that the version of the app that is running is unknown." 388 | }, 389 | "Unnamed Event" : { 390 | "comment" : "Text to display when a calendar event does not have a name." 391 | }, 392 | "Upcoming" : { 393 | "comment" : "Picker label to filter down upcoming calendar events." 394 | }, 395 | "URL" : { 396 | "comment" : "Placeholder text for text field for entering event URL." 397 | }, 398 | "Username" : { 399 | "comment" : "Section title for username entry." 400 | }, 401 | "Usernames are not unique and not used for signing into an account. More than one person can have the same username." : { 402 | "comment" : "Footer text to explain usernames." 403 | }, 404 | "Version" : { 405 | "comment" : "Label for the app version in the settings view." 406 | }, 407 | "View Profile" : { 408 | "comment" : "Button to view the active profile." 409 | }, 410 | "Write" : { 411 | "comment" : "Picker label to specify preference of only writing to a relay." 412 | }, 413 | "wss://relay.example.com" : { 414 | "comment" : "Example URL of a Nostr relay address." 415 | }, 416 | "You have entered a private key, which means you will be able to view, create, modify, and RSVP to events." : { 417 | "comment" : "Footer text indicating what it means to have a private key entered." 418 | }, 419 | "You have entered a public key, which means you will be able to only view events." : { 420 | "comment" : "Footer text indicating what it means to use a public key." 421 | }, 422 | "You have entered an incorrect private key, which means you will be not be able to create, modify, or RSVP to events." : { 423 | "comment" : "Footer text indicating what it means to have an incorrect private key entered." 424 | }, 425 | "You have not entered a private key, which means you will be not be able to create, modify, or RSVP to events." : { 426 | "comment" : "Footer text indicating what it means to not have a private key entered." 427 | }, 428 | "Your app settings will be deleted from this device. Your data on Nostr relays will not be affected." : { 429 | "comment" : "Message to inform user about what will happen if they sign out." 430 | } 431 | }, 432 | "version" : "1.0" 433 | } -------------------------------------------------------------------------------- /Comingle/Views/CreateOrModifyEventView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateOrModifyEventView.swift 3 | // Comingle 4 | // 5 | // Created by Terry Yiu on 7/29/24. 6 | // 7 | 8 | import GeohashKit 9 | import Kingfisher 10 | import MapKit 11 | import NostrSDK 12 | import OrderedCollections 13 | import SwiftData 14 | import SwiftUI 15 | 16 | struct CreateOrModifyEventView: View { 17 | @Environment(\.dismiss) private var dismiss 18 | 19 | @State private var viewModel: ViewModel 20 | 21 | init(appState: AppState, existingEvent: TimeBasedCalendarEvent? = nil) { 22 | let viewModel = ViewModel(appState: appState, existingEvent: existingEvent) 23 | _viewModel = State(initialValue: viewModel) 24 | } 25 | 26 | var body: some View { 27 | if viewModel.appState.keypair != nil && (viewModel.existingEvent == nil || viewModel.appState.publicKey?.hex == viewModel.existingEvent?.pubkey) { 28 | Form { 29 | let eventTitle = String(localized: "Text indicating that the field is for entering an event title.", comment: "Text indicating that the field is for entering an event title.") 30 | Section { 31 | TextField(eventTitle, text: $viewModel.title) 32 | } header: { 33 | Text(eventTitle) 34 | } 35 | 36 | let eventSummary = String(localized: "Summary", comment: "Section title for summary section that summarizes the calendar event.") 37 | Section { 38 | TextField(eventSummary, text: $viewModel.summary) 39 | } header: { 40 | Text(eventSummary) 41 | } 42 | 43 | Section { 44 | TextEditor(text: $viewModel.description) 45 | } header: { 46 | Text("Event Description", comment: "Section title for event description.") 47 | } 48 | 49 | Section { 50 | TextField(String(localized: "https://example.com/image.png", comment: "Example image URL of a calendar event image."), text: $viewModel.imageString) 51 | .textContentType(.URL) 52 | .autocorrectionDisabled() 53 | .textInputAutocapitalization(.never) 54 | 55 | if let validatedImageURL = viewModel.validatedImageURL { 56 | KFImage.url(validatedImageURL) 57 | .resizable() 58 | .placeholder { ProgressView() } 59 | .scaledToFit() 60 | .frame(maxWidth: 100, maxHeight: 200) 61 | } 62 | } header: { 63 | Text("Image", comment: "Section title for image of the event.") 64 | } 65 | 66 | Section { 67 | Button(action: { 68 | viewModel.isShowingLocationSelector = true 69 | }, label: { 70 | let trimmedLocation = viewModel.location.trimmingCharacters(in: .whitespacesAndNewlines) 71 | if trimmedLocation.isEmpty { 72 | Text("Add a location", comment: "Button to navigate to event location picker sheet.") 73 | } else { 74 | Text(trimmedLocation) 75 | } 76 | }) 77 | 78 | let trimmedGeohash = viewModel.trimmedGeohash 79 | if !trimmedGeohash.isEmpty, let geohash = Geohash(geohash: trimmedGeohash) { 80 | Map(bounds: MapCameraBounds(centerCoordinateBounds: geohash.region)) { 81 | Marker(viewModel.trimmedTitle, coordinate: geohash.region.center) 82 | } 83 | .frame(height: 250) 84 | } 85 | } header: { 86 | Text("Location", comment: "Confirmation dialog for taking action on the location of a calendar event.") 87 | } 88 | 89 | Section { 90 | DatePicker( 91 | String(localized: "Starts", comment: "Text indicating that the form field is for setting the event start date and time."), 92 | selection: $viewModel.start, 93 | displayedComponents: [.date, .hourAndMinute] 94 | ) 95 | .datePickerStyle(.compact) 96 | .environment(\.timeZone, viewModel.startTimeZoneOrCurrent) 97 | 98 | DatePicker( 99 | String(localized: "Ends", comment: "Text indicating that the form field is for setting the event end date and time."), 100 | selection: $viewModel.end, 101 | displayedComponents: [.date, .hourAndMinute] 102 | ) 103 | .datePickerStyle(.compact) 104 | .environment(\.timeZone, viewModel.startTimeZoneOrCurrent) 105 | 106 | Toggle(String(localized: "Set Time Zone", comment: "Text for toggle for setting time zone on an event."), isOn: $viewModel.isSettingTimeZone) 107 | 108 | if viewModel.isSettingTimeZone { 109 | Button(action: { 110 | viewModel.isShowingTimeZoneSelector = true 111 | }, label: { 112 | Text(viewModel.startTimeZoneOrCurrent.displayName(for: viewModel.start)) 113 | }) 114 | } 115 | } header: { 116 | Text("Time", comment: "Section title for the event time section.") 117 | } footer: { 118 | Text("Enter a time zone if this is primarily an in-person event.", comment: "Footer text to explain when a time zone should be entered.") 119 | } 120 | 121 | Section { 122 | Button(action: { 123 | viewModel.isShowingParticipantSelector = true 124 | }, label: { 125 | Text("\(viewModel.participants.count) participants", comment: "Number of invited participants") 126 | }) 127 | } header: { 128 | Text("Participants", comment: "Section title for participants in event creation view.") 129 | } footer: { 130 | Text("Anyone who is not invited can still RSVP to public events. It is up to you to decide if you want to explicitly invite a participant or who can attend the event.", comment: "Footer text explaining what it means to invite a participant.") 131 | } 132 | 133 | Section { 134 | ForEach(viewModel.references, id: \.self) { reference in 135 | Text(reference.absoluteString) 136 | .swipeActions { 137 | Button(role: .destructive) { 138 | viewModel.references.remove(reference) 139 | } label: { 140 | Image(systemName: "minus.circle") 141 | } 142 | } 143 | } 144 | 145 | HStack { 146 | TextField(String(localized: "URL", comment: "Placeholder text for text field for entering event URL."), text: $viewModel.referenceToAdd) 147 | .textContentType(.URL) 148 | .autocorrectionDisabled() 149 | .textInputAutocapitalization(.never) 150 | 151 | Spacer() 152 | 153 | Button( 154 | action: { 155 | if let validatedReferenceURL = viewModel.validatedReferenceURL { 156 | viewModel.references.append(validatedReferenceURL) 157 | viewModel.referenceToAdd = "" 158 | } 159 | }, 160 | label: { 161 | Image(systemName: "plus.circle") 162 | } 163 | ) 164 | .disabled(viewModel.validatedReferenceURL == nil) 165 | } 166 | } header: { 167 | Text("Links", comment: "Section title for reference links on an event.") 168 | } 169 | 170 | Section { 171 | Button( 172 | role: .destructive, 173 | action: { 174 | viewModel.reset() 175 | }, 176 | label: { 177 | if viewModel.existingEvent != nil { 178 | Text("Restore Original Event", comment: "Button to restore event modification fields back to what the original event started with.") 179 | } else { 180 | Text("Reset All Fields", comment: "Button to reset all fields to the starting point of a fresh event creation.") 181 | } 182 | } 183 | ) 184 | } 185 | } 186 | .navigationTitle(viewModel.navigationTitle) 187 | .sheet(isPresented: $viewModel.isShowingLocationSelector) { 188 | NavigationStack { 189 | LocationSearchView(location: $viewModel.location, geohash: $viewModel.geohash) 190 | } 191 | .presentationDragIndicator(.visible) 192 | } 193 | .sheet(isPresented: $viewModel.isShowingTimeZoneSelector) { 194 | NavigationStack { 195 | TimeZoneSelectionView(date: viewModel.start, timeZone: $viewModel.startTimeZone) 196 | } 197 | .presentationDragIndicator(.visible) 198 | } 199 | .sheet(isPresented: $viewModel.isShowingParticipantSelector) { 200 | NavigationStack { 201 | ParticipantSearchView(appState: viewModel.appState, participants: $viewModel.participants) 202 | } 203 | .presentationDragIndicator(.visible) 204 | } 205 | .toolbar { 206 | ToolbarItem(placement: .primaryAction) { 207 | Button(action: { 208 | if viewModel.saveEvent() { 209 | dismiss() 210 | } 211 | }, label: { 212 | Text("Save", comment: "Button to save a form.") 213 | }) 214 | .disabled(!viewModel.canSave) 215 | } 216 | } 217 | } else { 218 | // This view should not be used unless the user is signed in with a private key. 219 | // Therefore, this EmptyView technically should never be shown. 220 | EmptyView() 221 | } 222 | } 223 | } 224 | 225 | class EventCreationParticipant: Equatable, Hashable { 226 | static func == (lhs: EventCreationParticipant, rhs: EventCreationParticipant) -> Bool { 227 | lhs.publicKeyHex == rhs.publicKeyHex 228 | } 229 | 230 | func hash(into hasher: inout Hasher) { 231 | hasher.combine(publicKeyHex) 232 | } 233 | 234 | let publicKeyHex: String 235 | var relayURL: URL? 236 | var role: String = "" 237 | 238 | init(publicKeyHex: String, relayURL: URL? = nil, role: String = "") { 239 | self.publicKeyHex = publicKeyHex 240 | self.relayURL = relayURL 241 | self.role = role 242 | } 243 | } 244 | 245 | extension CreateOrModifyEventView { 246 | @Observable class ViewModel: EventCreating { 247 | let appState: AppState 248 | 249 | let existingEvent: TimeBasedCalendarEvent? 250 | 251 | var title: String = "" 252 | var summary: String = "" 253 | var imageString: String = "" 254 | var start: Date = Date.now 255 | var end: Date = Date.now 256 | var description: String = "" 257 | 258 | var location: String = "" 259 | var isShowingLocationSelector: Bool = false 260 | var geohash: String = "" 261 | 262 | var hashtags = OrderedSet() 263 | 264 | var references = OrderedSet() 265 | var referenceToAdd: String = "" 266 | 267 | var isSettingTimeZone: Bool = false 268 | var startTimeZone: TimeZone? 269 | var isShowingTimeZoneSelector: Bool = false 270 | var isShowingParticipantSelector: Bool = false 271 | 272 | var participants = Set() 273 | 274 | init(appState: AppState, existingEvent: TimeBasedCalendarEvent?) { 275 | self.appState = appState 276 | self.existingEvent = existingEvent 277 | reset() 278 | } 279 | 280 | func reset() { 281 | title = existingEvent?.title ?? "" 282 | summary = existingEvent?.summary ?? "" 283 | imageString = existingEvent?.imageURL?.absoluteString ?? "" 284 | description = existingEvent?.content ?? "" 285 | let now = Date.now 286 | start = existingEvent?.startTimestamp ?? now 287 | end = existingEvent?.endTimestamp ?? existingEvent?.startTimestamp ?? now 288 | startTimeZone = existingEvent?.startTimeZone 289 | 290 | if existingEvent?.startTimeZone != nil { 291 | isSettingTimeZone = true 292 | } 293 | 294 | location = existingEvent?.locations.first ?? "" 295 | isShowingLocationSelector = false 296 | geohash = existingEvent?.geohash ?? "" 297 | hashtags = OrderedSet(existingEvent?.hashtags ?? []) 298 | references = OrderedSet(existingEvent?.references ?? []) 299 | referenceToAdd = "" 300 | 301 | startTimeZone = existingEvent?.startTimeZone 302 | 303 | existingEvent?.participants.forEach { 304 | if let pubkey = $0.pubkey { 305 | participants.insert(EventCreationParticipant(publicKeyHex: pubkey.hex, relayURL: $0.relayURL, role: $0.role ?? "")) 306 | } 307 | } 308 | 309 | isShowingTimeZoneSelector = false 310 | isShowingParticipantSelector = false 311 | } 312 | 313 | var startTimeZoneOrCurrent: TimeZone { 314 | if isSettingTimeZone { 315 | startTimeZone ?? TimeZone.autoupdatingCurrent 316 | } else { 317 | TimeZone.autoupdatingCurrent 318 | } 319 | } 320 | 321 | var trimmedTitle: String { 322 | title.trimmingCharacters(in: .whitespacesAndNewlines) 323 | } 324 | 325 | var trimmedImageString: String { 326 | imageString.trimmingCharacters(in: .whitespacesAndNewlines) 327 | } 328 | 329 | var trimmedSummary: String { 330 | summary.trimmingCharacters(in: .whitespacesAndNewlines) 331 | } 332 | 333 | var trimmedGeohash: String { 334 | geohash.trimmingCharacters(in: .whitespacesAndNewlines) 335 | } 336 | 337 | var navigationTitle: String { 338 | if existingEvent != nil { 339 | String(localized: "Modify Event", comment: "Button to modify event.") 340 | } else { 341 | String(localized: "Create an Event", comment: "Navigation title for the view to create an event.") 342 | } 343 | } 344 | 345 | var validatedImageURL: URL? { 346 | guard let url = URL(string: trimmedImageString) else { 347 | return nil 348 | } 349 | 350 | return url 351 | } 352 | 353 | var validatedReferenceURL: URL? { 354 | URL(string: referenceToAdd) 355 | } 356 | 357 | var canSave: Bool { 358 | appState.keypair != nil && start <= end && !trimmedTitle.isEmpty && (trimmedImageString.isEmpty || validatedImageURL != nil) 359 | } 360 | 361 | func saveEvent() -> Bool { 362 | guard let keypair = appState.keypair else { 363 | return false 364 | } 365 | 366 | do { 367 | let calendarEventParticipants = participants.compactMap { 368 | if let publicKey = PublicKey(hex: $0.publicKeyHex) { 369 | let trimmedRole = $0.role.trimmingCharacters(in: .whitespacesAndNewlines) 370 | if trimmedRole.isEmpty { 371 | return CalendarEventParticipant(pubkey: publicKey) 372 | } else { 373 | return CalendarEventParticipant(pubkey: publicKey, role: $0.role) 374 | } 375 | } else { 376 | return nil 377 | } 378 | } 379 | 380 | let endOrNil: Date? 381 | if end == start { 382 | endOrNil = nil 383 | } else { 384 | endOrNil = end 385 | } 386 | 387 | let startTimeZoneOrNil: TimeZone? 388 | if !isSettingTimeZone { 389 | startTimeZoneOrNil = nil 390 | } else { 391 | startTimeZoneOrNil = startTimeZoneOrCurrent 392 | } 393 | 394 | let locationsOrNil: [String]? 395 | let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines) 396 | if trimmedLocation.isEmpty { 397 | locationsOrNil = nil 398 | } else { 399 | locationsOrNil = [trimmedLocation] 400 | } 401 | 402 | let hashtagsOrNil: [String]? 403 | if hashtags.isEmpty { 404 | hashtagsOrNil = nil 405 | } else { 406 | hashtagsOrNil = Array(hashtags) 407 | } 408 | 409 | let referencesOrNil: [URL]? 410 | if references.isEmpty { 411 | referencesOrNil = nil 412 | } else { 413 | referencesOrNil = Array(references) 414 | } 415 | 416 | let event = try timeBasedCalendarEvent( 417 | withIdentifier: existingEvent?.identifier ?? UUID().uuidString, 418 | title: trimmedTitle, 419 | summary: summary.trimmedOrNilIfEmpty, 420 | imageURL: validatedImageURL, 421 | description: description.trimmingCharacters(in: .whitespacesAndNewlines), 422 | startTimestamp: start, 423 | endTimestamp: endOrNil, 424 | startTimeZone: startTimeZoneOrNil, 425 | locations: locationsOrNil, 426 | geohash: geohash.trimmedOrNilIfEmpty, 427 | participants: calendarEventParticipants, 428 | hashtags: hashtagsOrNil, 429 | references: referencesOrNil, 430 | signedBy: keypair 431 | ) 432 | 433 | if let calendarEventCoordinates = event.replaceableEventCoordinates()?.tag.value { 434 | appState.timeBasedCalendarEvents[calendarEventCoordinates] = event 435 | 436 | let persistentNostrEvent = PersistentNostrEvent(nostrEvent: event) 437 | appState.modelContext.insert(persistentNostrEvent) 438 | try appState.modelContext.save() 439 | 440 | appState.updateEventsTrie(oldEvent: existingEvent, newEvent: event) 441 | 442 | appState.relayWritePool.publishEvent(event) 443 | 444 | return true 445 | } 446 | } catch { 447 | print("Unable to save time based calendar event. \(error)") 448 | } 449 | 450 | return false 451 | } 452 | } 453 | } 454 | 455 | //#Preview { 456 | // EventCreationOrModificationView() 457 | //} 458 | --------------------------------------------------------------------------------