├── 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 |
12 |
--------------------------------------------------------------------------------
/Comingle/Assets/Assets.xcassets/ComingleLogo.imageset/ComingleLogoDark 2.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/Comingle/Assets/Assets.xcassets/ComingleLogo.imageset/ComingleLogoDark.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/Comingle/Assets/Assets.xcassets/ComingleLogo.imageset/ComingleLogoLight 1.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/Comingle/Assets/Assets.xcassets/ComingleLogo.imageset/ComingleLogoLight 2.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/Comingle/Assets/Assets.xcassets/ComingleLogo.imageset/ComingleLogoLight.svg:
--------------------------------------------------------------------------------
1 |
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 |
--------------------------------------------------------------------------------