├── .gitignore ├── Circles.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcshareddata │ └── xcschemes │ │ └── Circles.xcscheme └── xcuserdata │ ├── cvwright.xcuserdatad │ ├── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ │ └── xcschememanagement.plist │ └── ramius.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── Circles ├── .DS_Store ├── Assets.xcassets │ ├── .DS_Store │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Circles-app-icon-1024.png │ │ └── Contents.json │ ├── Contents.json │ ├── LaunchScreen │ │ ├── Contents.json │ │ ├── LaunchColor.colorset │ │ │ └── Contents.json │ │ ├── LoginColor.colorset │ │ │ └── Contents.json │ │ ├── launch-circle-logo.imageset │ │ │ ├── Contents.json │ │ │ └── launch-circle-logo.svg │ │ ├── launch-logo-purple.imageset │ │ │ ├── Contents.json │ │ │ └── launch-logo-purple.svg │ │ └── launchcirclebackground.imageset │ │ │ ├── Contents.json │ │ │ └── launchcirclebackground.png │ ├── MColor.colorset │ │ └── Contents.json │ ├── RegistrationScreen │ │ ├── Contents.json │ │ ├── IconFilledArrowBack.imageset │ │ │ ├── Contents.json │ │ │ └── IconFilledArrowBack.svg │ │ ├── Page.imageset │ │ │ ├── Contents.json │ │ │ └── Page.svg │ │ ├── acceptedCircle.imageset │ │ │ ├── Contents.json │ │ │ └── acceptedCircle.svg │ │ └── forwardArrow.imageset │ │ │ ├── Contents.json │ │ │ └── forwardArrow.svg │ ├── Stock Photos │ │ ├── .DS_Store │ │ ├── Contents.json │ │ ├── iStock-1176559812.imageset │ │ │ ├── Contents.json │ │ │ └── iStock-1176559812-10.jpg │ │ ├── iStock-1225782571.imageset │ │ │ ├── Contents.json │ │ │ └── iStock-1225782571-400.jpeg │ │ ├── iStock-1304744459.imageset │ │ │ ├── Contents.json │ │ │ └── iStock-1304744459-400.jpeg │ │ ├── iStock-1356527683.imageset │ │ │ ├── Contents.json │ │ │ └── iStock-1356527683-400.jpeg │ │ └── iStock-640313068.imageset │ │ │ ├── Contents.json │ │ │ └── iStock-640313068-400.jpeg │ ├── circles-logo-dark.imageset │ │ ├── Contents.json │ │ └── circles-logo-dark.png │ └── circles-logo-light.imageset │ │ ├── Contents.json │ │ └── circles-logo-light.png ├── Backend │ ├── AppStoreInterface.swift │ ├── CirclesAppDelegate.swift │ ├── CirclesApplicationSession.swift │ └── CirclesStore.swift ├── Changelogs │ ├── CHANGELOG_Full.md │ └── CHANGELOG_LastUpdates.md ├── Circles-Bridging-Header.h ├── Circles.entitlements ├── CirclesApp.swift ├── CirclesError.swift ├── CirclesTabbedInterface.swift ├── Constants.swift ├── ContentView.swift ├── DebugMode.swift ├── Info.plist ├── Launch Screen.storyboard ├── Matrix │ ├── Account Data │ │ ├── mDirectContent.swift │ │ └── mIgnoredUserListContent.swift │ └── MatrixAccountDataType.swift ├── Models │ ├── ApplicationState.swift │ ├── CirclesConfigV1.swift │ ├── CirclesConfigV2.swift │ ├── ContainerRoom.swift │ ├── GalleryRoom.swift │ ├── GroupRoom.swift │ ├── PersonRoom.swift │ ├── PowerLevel.swift │ ├── ProfileSpace.swift │ ├── Room+URL.swift │ └── TimelineSpace.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── PrivacyInfo.xcprivacy ├── Utilities │ ├── Array+Extension.swift │ ├── Array+RawRepresentable.swift │ ├── Array+Sample.swift │ ├── AsyncButton.swift │ ├── BCrypt │ │ ├── PerfectBCrypt.swift │ │ ├── bcrypt.c │ │ ├── blf.c │ │ ├── blf.h │ │ ├── portable_endian.h │ │ └── pycabcrypt.h │ ├── BlurHashDecode.swift │ ├── BlurHashEncode.swift │ ├── Color+extensions.swift │ ├── CustomFonts.swift │ ├── Data+hex.swift │ ├── DeleteAndPurge.swift │ ├── EmojiPicker.swift │ ├── HtmlView.swift │ ├── ImageSaver.swift │ ├── Keychain.swift │ ├── LegacySecretStoreKeygen.swift │ ├── Movie.swift │ ├── QR.swift │ ├── RelativeTimestampFormatter.swift │ ├── SaveImage.swift │ ├── SecureFieldWithEye.swift │ ├── Sequence+asyncMap.swift │ ├── SystemImages.swift │ ├── ToastPresenter.swift │ ├── Utils.swift │ ├── WebView.swift │ └── zxcvbn │ │ ├── DBMatcher.h │ │ ├── DBMatcher.m │ │ ├── DBPasswordStrengthMeterView.h │ │ ├── DBPasswordStrengthMeterView.m │ │ ├── DBScorer.h │ │ ├── DBScorer.m │ │ ├── DBZxcvbn.h │ │ ├── DBZxcvbn.m │ │ ├── Zxcvbn.h │ │ ├── Zxcvbn.swift │ │ └── generated │ │ ├── adjacency_graphs.json │ │ └── frequency_lists.json ├── Views │ ├── Circles │ │ ├── ChangelogSheet.swift │ │ ├── CircleCreationSheet.swift │ │ ├── CircleFollowingRow.swift │ │ ├── CircleInvitationsIndicator.swift │ │ ├── CircleInvitationsView.swift │ │ ├── CirclePicker.swift │ │ ├── CircleRenameView.swift │ │ ├── CirclesOverviewScreen.swift │ │ ├── FollowingTimelineDetailsView.swift │ │ ├── InvitedCircleCard.swift │ │ ├── InvitedCircleDetailView.swift │ │ ├── SingleTimelineSettingsView.swift │ │ ├── SingleTimelineView.swift │ │ ├── TimelineOverviewCard.swift │ │ ├── UnifiedTimelineComposerSheet.swift │ │ ├── UnifiedTimelineSettingsView.swift │ │ └── UnifiedTimelineView.swift │ ├── CirclesLogoView.swift │ ├── Composer │ │ ├── CloudImagePicker.swift │ │ ├── ImagePicker.swift │ │ ├── PhotoPicker.swift │ │ ├── PostComposer.swift │ │ ├── PostComposerScreen.swift │ │ └── VideoPicker.swift │ ├── Groups │ │ ├── GenericPersonDetailView.swift │ │ ├── GroupCreationSheet.swift │ │ ├── GroupHeader.swift │ │ ├── GroupInvitationsIndicator.swift │ │ ├── GroupInvitationsView.swift │ │ ├── GroupOverviewRow.swift │ │ ├── GroupSettingsView.swift │ │ ├── GroupTimelineScreen.swift │ │ ├── GroupsOverviewScreen.swift │ │ ├── InvitedGroupCard.swift │ │ ├── InvitedGroupDetailView.swift │ │ └── LikedEmojiView.swift │ ├── Help │ │ └── CirclesHelpView.swift │ ├── Home │ │ └── SystemNoticesView.swift │ ├── Login │ │ ├── ForgotPasswordView.swift │ │ ├── LegacyLoginScreen.swift │ │ ├── SecretStorageCreationScreen.swift │ │ ├── SecretStoragePasswordScreen.swift │ │ ├── UiaLoginScreen.swift │ │ └── WelcomeScreen.swift │ ├── People │ │ ├── FollowersView.swift │ │ ├── FollowingView.swift │ │ ├── FriendsOfFriendsView.swift │ │ ├── InviteToFollowMeView.swift │ │ ├── MutualFriendsSection.swift │ │ ├── PeopleInvitationsIndicator.swift │ │ ├── PeopleInvitationsView.swift │ │ ├── PeopleOverviewScreen.swift │ │ ├── PersonDetailView.swift │ │ ├── PersonHeaderRow.swift │ │ ├── PersonsCircleRow.swift │ │ └── SelfDetailView.swift │ ├── Photo Galleries │ │ ├── GalleryGridView.swift │ │ ├── GalleryInvitationsIndicator.swift │ │ ├── GalleryInvitationsView.swift │ │ ├── GalleryInviteCard.swift │ │ ├── GallerySettingsView.swift │ │ ├── PhotoContextMenu.swift │ │ ├── PhotoDetailView.swift │ │ ├── PhotoGalleryCard.swift │ │ ├── PhotoGalleryCreationSheet.swift │ │ ├── PhotoGalleryView.swift │ │ ├── PhotoThumbnailCard.swift │ │ ├── PhotosOverviewScreen.swift │ │ ├── PhotosUploadView.swift │ │ ├── VideoDetailView.swift │ │ └── VideoThumbnailCard.swift │ ├── Rooms │ │ ├── BannedRoomMemberRow.swift │ │ ├── KnockOnRoomView.swift │ │ ├── KnockingUserCard.swift │ │ ├── RoomAvatarView.swift │ │ ├── RoomCryptoInfoView.swift │ │ ├── RoomDebugDetailsSection.swift │ │ ├── RoomDefaultPowerLevelPicker.swift │ │ ├── RoomInviteOneUserSheet.swift │ │ ├── RoomInviteSheet.swift │ │ ├── RoomInvitedMemberRow.swift │ │ ├── RoomKnockDetailsView.swift │ │ ├── RoomKnockIndicator.swift │ │ ├── RoomMemberDetailView.swift │ │ ├── RoomMemberRow.swift │ │ ├── RoomMembersSection.swift │ │ ├── RoomMembersSheet.swift │ │ ├── RoomRenameView.swift │ │ ├── RoomSecurityInfoSheet.swift │ │ ├── RoomShareSheet.swift │ │ ├── RoomTopicEditorView.swift │ │ ├── ScanQrCodeAndKnockSheet.swift │ │ ├── ScanQrCodeView.swift │ │ └── UsersToInviteView.swift │ ├── Settings │ │ ├── AcknowledgementsView.swift │ │ ├── DeactivateAccountCustomAlertView.swift │ │ ├── DeactivateAccountView.swift │ │ ├── DeviceDetailsView.swift │ │ ├── DeviceInfoView.swift │ │ ├── DevicesScreen.swift │ │ ├── EmailSettingsView.swift │ │ ├── IgnoredUsersView.swift │ │ ├── NotificationsSettingsView.swift │ │ ├── ProfileSettingsView.swift │ │ ├── SecuritySettingsView.swift │ │ ├── SettingsScreen.swift │ │ ├── StorageSettingsView.swift │ │ ├── SubscriptionSettingsView.swift │ │ ├── UpdateDisplaynameView.swift │ │ └── UpdateStatusMessageView.swift │ ├── Setup │ │ ├── CircleSetupInfo.swift │ │ ├── SetupAddNewCircleSheet.swift │ │ ├── SetupAvatarView.swift │ │ ├── SetupCircleCard.swift │ │ ├── SetupCirclesView.swift │ │ ├── SetupIntroToCircles.swift │ │ └── SetupScreen.swift │ ├── Signup │ │ ├── AccountInfoForm.swift │ │ ├── SignUpScreen.swift │ │ ├── SignupFinishedView.swift │ │ ├── SignupStartForm.swift │ │ └── TokenForm.swift │ ├── Timeline │ │ ├── CommentCard.swift │ │ ├── CommentsView.swift │ │ ├── LikeButton.swift │ │ ├── MessageAuthorHeader.swift │ │ ├── MessageCard.swift │ │ ├── MessageContextMenu.swift │ │ ├── MessageReportingSheet.swift │ │ ├── MessageSheetType.swift │ │ ├── MessageThumbnail.swift │ │ ├── MessageView.swift │ │ ├── StateEventView.swift │ │ ├── ThreadView.swift │ │ ├── TimelineView.swift │ │ ├── TimelineViewModel.swift │ │ ├── UnifiedTimeline.swift │ │ ├── UserAvatarView.swift │ │ ├── UserNameView.swift │ │ └── VideoContentView.swift │ ├── UIA │ │ ├── BsspekeEnrollOprfForm.swift │ │ ├── BsspekeEnrollSaveForm.swift │ │ ├── BsspekeLoginForm.swift │ │ ├── EmailEnrollRequestTokenForm.swift │ │ ├── EmailEnrollSubmitTokenForm.swift │ │ ├── EmailLoginRequestTokenForm.swift │ │ ├── EmailLoginSubmitTokenForm.swift │ │ ├── FreeSubscriptionForm.swift │ │ ├── LegacyPasswordUiaForm.swift │ │ ├── SubscriptionUIaForm.swift │ │ ├── TermsOfServiceForm.swift │ │ ├── UiaInProgressView.swift │ │ ├── UiaOverlayView.swift │ │ ├── UiaView.swift │ │ └── UsernameEnrollForm.swift │ └── View+Extensions │ │ ├── ButtonStyle+Extensions.swift │ │ ├── UIDevice+Extensions.swift │ │ ├── View+Extentions.swift │ │ └── ViewModifier+Extensions.swift └── fonts │ ├── Inter-VariableFont.ttf │ └── Nunito-VariableFont.ttf ├── FUTO Circles.storekit ├── LICENSE ├── NSE ├── Constants.swift ├── Info.plist ├── NSE.entitlements └── NotificationService.swift ├── Products.storekit ├── README.md └── assets ├── .DS_Store └── images ├── circles-and-groups.jpeg ├── circles-screenshots.jpeg ├── groups-screenshots.jpeg ├── kickstarter-logo-green.png └── photogallery-screenshots.jpeg /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Internal 9 | *Package.resolved 10 | *UserInterfaceState.xcuserstate 11 | *xcschememanagement.plist 12 | 13 | ## Obj-C/Swift specific 14 | *.hmap 15 | 16 | ## App packaging 17 | *.ipa 18 | *.dSYM.zip 19 | *.dSYM 20 | 21 | ## Playgrounds 22 | timeline.xctimeline 23 | playground.xcworkspace 24 | 25 | # Swift Package Manager 26 | # 27 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 28 | # Packages/ 29 | # Package.pins 30 | # Package.resolved 31 | # *.xcodeproj 32 | # 33 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 34 | # hence it is not needed unless you have added a package configuration file to your project 35 | # .swiftpm 36 | 37 | .build/ 38 | 39 | # CocoaPods 40 | # 41 | # We recommend against adding the Pods directory to your .gitignore. However 42 | # you should judge for yourself, the pros and cons are mentioned at: 43 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 44 | # 45 | # Pods/ 46 | # 47 | # Add this line if you want to avoid checking in source code from the Xcode workspace 48 | # *.xcworkspace 49 | 50 | # Carthage 51 | # 52 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 53 | # Carthage/Checkouts 54 | 55 | Carthage/Build/ 56 | 57 | # fastlane 58 | # 59 | # It is recommended to not store the screenshots in the git repo. 60 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 61 | # For more information about the recommended setup visit: 62 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 63 | 64 | fastlane/report.xml 65 | fastlane/Preview.html 66 | fastlane/screenshots/**/*.png 67 | fastlane/test_output 68 | 69 | # Others 70 | # MacOS 71 | .DS_Store 72 | # Vim 73 | *.swo 74 | *.swp 75 | -------------------------------------------------------------------------------- /Circles.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Circles.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Circles.xcodeproj/xcuserdata/cvwright.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /Circles.xcodeproj/xcuserdata/ramius.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Circles.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 9 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | 404C72CC265DDF35000473DF 16 | 17 | primary 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Circles/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/Circles/.DS_Store -------------------------------------------------------------------------------- /Circles/Assets.xcassets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/Circles/Assets.xcassets/.DS_Store -------------------------------------------------------------------------------- /Circles/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.161", 10 | "red" : "0.404" 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" : "1.000", 27 | "green" : "0.161", 28 | "red" : "0.404" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/AppIcon.appiconset/Circles-app-icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/Circles/Assets.xcassets/AppIcon.appiconset/Circles-app-icon-1024.png -------------------------------------------------------------------------------- /Circles/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Circles-app-icon-1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/LaunchScreen/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/LaunchScreen/LaunchColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.161", 10 | "red" : "0.404" 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" : "1.000", 27 | "green" : "0.161", 28 | "red" : "0.404" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/LaunchScreen/LoginColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.902", 9 | "green" : "0.882", 10 | "red" : "0.871" 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" : "0.902", 27 | "green" : "0.882", 28 | "red" : "0.871" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/LaunchScreen/launch-circle-logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "launch-circle-logo.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/LaunchScreen/launch-logo-purple.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "launch-logo-purple.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/LaunchScreen/launchcirclebackground.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "launchcirclebackground.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/LaunchScreen/launchcirclebackground.imageset/launchcirclebackground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/Circles/Assets.xcassets/LaunchScreen/launchcirclebackground.imageset/launchcirclebackground.png -------------------------------------------------------------------------------- /Circles/Assets.xcassets/MColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 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" : "0.000", 27 | "green" : "0.000", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/RegistrationScreen/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/RegistrationScreen/IconFilledArrowBack.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "IconFilledArrowBack.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/RegistrationScreen/IconFilledArrowBack.imageset/IconFilledArrowBack.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/RegistrationScreen/Page.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Page.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/RegistrationScreen/Page.imageset/Page.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/RegistrationScreen/acceptedCircle.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "acceptedCircle.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/RegistrationScreen/acceptedCircle.imageset/acceptedCircle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/RegistrationScreen/forwardArrow.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "forwardArrow.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/RegistrationScreen/forwardArrow.imageset/forwardArrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/Stock Photos/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/Circles/Assets.xcassets/Stock Photos/.DS_Store -------------------------------------------------------------------------------- /Circles/Assets.xcassets/Stock Photos/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/Stock Photos/iStock-1176559812.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "iStock-1176559812-10.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/Stock Photos/iStock-1176559812.imageset/iStock-1176559812-10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/Circles/Assets.xcassets/Stock Photos/iStock-1176559812.imageset/iStock-1176559812-10.jpg -------------------------------------------------------------------------------- /Circles/Assets.xcassets/Stock Photos/iStock-1225782571.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "iStock-1225782571-400.jpeg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/Stock Photos/iStock-1225782571.imageset/iStock-1225782571-400.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/Circles/Assets.xcassets/Stock Photos/iStock-1225782571.imageset/iStock-1225782571-400.jpeg -------------------------------------------------------------------------------- /Circles/Assets.xcassets/Stock Photos/iStock-1304744459.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "iStock-1304744459-400.jpeg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/Stock Photos/iStock-1304744459.imageset/iStock-1304744459-400.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/Circles/Assets.xcassets/Stock Photos/iStock-1304744459.imageset/iStock-1304744459-400.jpeg -------------------------------------------------------------------------------- /Circles/Assets.xcassets/Stock Photos/iStock-1356527683.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "iStock-1356527683-400.jpeg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/Stock Photos/iStock-1356527683.imageset/iStock-1356527683-400.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/Circles/Assets.xcassets/Stock Photos/iStock-1356527683.imageset/iStock-1356527683-400.jpeg -------------------------------------------------------------------------------- /Circles/Assets.xcassets/Stock Photos/iStock-640313068.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "iStock-640313068-400.jpeg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/Stock Photos/iStock-640313068.imageset/iStock-640313068-400.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/Circles/Assets.xcassets/Stock Photos/iStock-640313068.imageset/iStock-640313068-400.jpeg -------------------------------------------------------------------------------- /Circles/Assets.xcassets/circles-logo-dark.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "circles-logo-dark.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/circles-logo-dark.imageset/circles-logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/Circles/Assets.xcassets/circles-logo-dark.imageset/circles-logo-dark.png -------------------------------------------------------------------------------- /Circles/Assets.xcassets/circles-logo-light.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "circles-logo-light.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Circles/Assets.xcassets/circles-logo-light.imageset/circles-logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/Circles/Assets.xcassets/circles-logo-light.imageset/circles-logo-light.png -------------------------------------------------------------------------------- /Circles/Changelogs/CHANGELOG_LastUpdates.md: -------------------------------------------------------------------------------- 1 | # v1.0.7 2 | * New app icon 3 | * Remove FUTO branding 4 | * Add notice that FUTO servers will be closing down 5 | * Fix crash when downgrading from Circles 1.1.x 6 | -------------------------------------------------------------------------------- /Circles/Circles-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | #include "pycabcrypt.h" 6 | 7 | #import "Zxcvbn.h" 8 | 9 | -------------------------------------------------------------------------------- /Circles/Circles.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.associated-domains 8 | 9 | webcredentials:circu.li 10 | webcredentials:eu.circu.li 11 | webcredentials:*.circles-dev.net 12 | webcredentials:circles.futo.org 13 | applinks:circu.li 14 | applinks:*.circles-dev.net 15 | applinks:circles.futo.org 16 | 17 | com.apple.developer.authentication-services.autofill-credential-provider 18 | 19 | com.apple.security.app-sandbox 20 | 21 | com.apple.security.application-groups 22 | 23 | group.RX3RM99NR6.org.futo.circles 24 | 25 | com.apple.security.device.camera 26 | 27 | com.apple.security.network.client 28 | 29 | com.apple.security.personal-information.photos-library 30 | 31 | keychain-access-groups 32 | 33 | $(AppIdentifierPrefix)org.futo.circles 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Circles/CirclesApp.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020, 2021 Kombucha Digital Privacy Systems LLC 2 | // 3 | // CirclesApp.swift 4 | // Circles for iOS 5 | // 6 | // Created by Charles Wright on 5/25/21. 7 | // 8 | 9 | import SwiftUI 10 | import StoreKit 11 | import os 12 | import Matrix 13 | 14 | @main 15 | struct CirclesApp: App { 16 | @UIApplicationDelegateAdaptor(CirclesAppDelegate.self) var appDelegate 17 | 18 | @StateObject private var store = CirclesStore() 19 | private var paymentQueue = SKPaymentQueue.default() 20 | private var countryCode = SKPaymentQueue.default().storefront?.countryCode 21 | 22 | public static var logger = os.Logger(subsystem: "Circles", category: "Circles") 23 | 24 | init() { 25 | // We need to register all of our custom types with the Matrix library, so it can decode them for us 26 | Matrix.registerAccountDataType(EVENT_TYPE_CIRCLES_CONFIG_V1, CirclesConfigContentV1.self) 27 | Matrix.registerAccountDataType(EVENT_TYPE_CIRCLES_CONFIG_V2, CirclesConfigContentV2.self) 28 | 29 | print("CirclesApp: Done with init()") 30 | } 31 | 32 | var body: some Scene { 33 | WindowGroup { 34 | ContentView(store: store) 35 | .environmentObject(store) 36 | .onAppear { 37 | print("CirclesApp: onAppear") 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Circles/CirclesError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CirclesError.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/27/22. 6 | // 7 | 8 | import Foundation 9 | 10 | struct CirclesError: Error { 11 | var message: String 12 | 13 | init(_ message: String) { 14 | self.message = message 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Circles/DebugMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DebugMode.swift 3 | // Circles 4 | // 5 | // Created by Dmytro Ryshchuk on 6/2/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | class DebugModel { 12 | static let shared = DebugModel() 13 | 14 | @AppStorage("debugMode") var debugMode: Bool = false 15 | 16 | private init(debugMode: Bool = false) { 17 | self.debugMode = debugMode 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Circles/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIAppFonts 6 | 7 | Inter-VariableFont.ttf 8 | Nunito-VariableFont.ttf 9 | 10 | CFBundleDevelopmentRegion 11 | $(DEVELOPMENT_LANGUAGE) 12 | CFBundleExecutable 13 | $(EXECUTABLE_NAME) 14 | CFBundleIdentifier 15 | $(PRODUCT_BUNDLE_IDENTIFIER) 16 | CFBundleInfoDictionaryVersion 17 | 6.0 18 | CFBundleName 19 | $(PRODUCT_NAME) 20 | CFBundlePackageType 21 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 22 | CFBundleShortVersionString 23 | $(MARKETING_VERSION) 24 | CFBundleVersion 25 | $(CURRENT_PROJECT_VERSION) 26 | ITSAppUsesNonExemptEncryption 27 | 28 | LSRequiresIPhoneOS 29 | 30 | NSAppTransportSecurity 31 | 32 | NSAllowsArbitraryLoads 33 | 34 | 35 | NSCameraUsageDescription 36 | Circles needs access to the camera if you would like to take new photos to share in the app. 37 | NSFaceIDUsageDescription 38 | Circles uses FaceID to protect your cryptographic keys 39 | NSPhotoLibraryAddUsageDescription 40 | Circles needs access to the photo library to save the images that you download. 41 | UIApplicationSceneManifest 42 | 43 | UIApplicationSupportsMultipleScenes 44 | 45 | 46 | UIApplicationSupportsIndirectInputEvents 47 | 48 | UIBackgroundModes 49 | 50 | fetch 51 | remote-notification 52 | 53 | UILaunchStoryboardName 54 | Launch Screen.storyboard 55 | UIRequiredDeviceCapabilities 56 | 57 | armv7 58 | 59 | UIRequiresFullScreen 60 | 61 | UISupportedInterfaceOrientations 62 | 63 | UIInterfaceOrientationPortrait 64 | 65 | UISupportedInterfaceOrientations~ipad 66 | 67 | UIInterfaceOrientationLandscapeLeft 68 | UIInterfaceOrientationLandscapeRight 69 | UIInterfaceOrientationPortrait 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /Circles/Matrix/Account Data/mDirectContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // mDirectContent.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 6/27/22. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias mDirectContent = [UserId: [RoomId]] 11 | -------------------------------------------------------------------------------- /Circles/Matrix/Account Data/mIgnoredUserListContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // mIgnoredUserList.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/5/22. 6 | // 7 | 8 | import Foundation 9 | 10 | struct mIgnoredUserListContent: Codable { 11 | var ignoredUsers: [UserId: [String:String]] 12 | } 13 | -------------------------------------------------------------------------------- /Circles/Matrix/MatrixAccountDataType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MatrixAccountDataType.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 6/27/22. 6 | // 7 | 8 | import Foundation 9 | 10 | enum MatrixAccountDataType: Codable { 11 | case mIdentityServer // "m.identity_server" 12 | case mFullyRead // "m.fully_read" 13 | case mDirect // "m.direct" 14 | case mIgnoredUserList 15 | case mSecretStorageKey(String) // "m.secret_storage.key.[key ID]" 16 | 17 | init(from decoder: Decoder) throws { 18 | let string = try String(from: decoder) 19 | 20 | switch string { 21 | case "m.identity_server": 22 | self = .mIdentityServer 23 | return 24 | case "m.fully_read": 25 | self = .mFullyRead 26 | return 27 | 28 | case "m.direct": 29 | self = .mDirect 30 | return 31 | 32 | case "m.ignored_user_list": 33 | self = .mIgnoredUserList 34 | return 35 | 36 | default: 37 | 38 | // OK it's not one of the "normal" ones. Is it one of the weird ones? 39 | if string.starts(with: "m.secret_storage.key.") { 40 | guard let keyId = string.split(separator: ".").last 41 | else { 42 | let msg = "Couldn't get key id for m.secret_storage.key" 43 | print(msg) 44 | throw Matrix.Error(msg) 45 | } 46 | self = .mSecretStorageKey(String(keyId)) 47 | } 48 | 49 | // If we're still here, then we have *no* idea what to do with this thing. 50 | 51 | let msg = "Failed to decode MatrixAccountDataType from string [\(string)]" 52 | print(msg) 53 | throw Matrix.Error(msg) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Circles/Models/ApplicationState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApplicationState.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 5/10/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /* 11 | class ApplicationState: ObservableObject { 12 | enum State { 13 | case loading 14 | case none 15 | case signingUp(SignupSession) 16 | case loggingIn(UIAuthSession) 17 | case loggedInDisconnected(MatrixCredentials) 18 | case connected(MatrixInterface) 19 | } 20 | 21 | @Published var state: State 22 | 23 | init() { 24 | self.state = .loading 25 | _ = Task { 26 | await load() 27 | } 28 | } 29 | 30 | func load() async { 31 | guard let userId = UserDefaults.standard.string(forKey: "user_id"), 32 | !userId.isEmpty, 33 | let deviceId = UserDefaults.standard.string(forKey: "device_id[\(userId)]"), 34 | let accessToken = UserDefaults.standard.string(forKey: "access_token[\(userId)]"), 35 | !accessToken.isEmpty 36 | else { 37 | // Apparently we're offline, waiting for (valid) credentials to log in 38 | self.state = .none 39 | return 40 | } 41 | 42 | // Update our state so that the application knows we're working on connecting to the server 43 | self.state = .loggedInDisconnected(MatrixCredentials(accessToken: accessToken, deviceId: deviceId, userId: userId)) 44 | 45 | // Now actually start trying to connect 46 | do { 47 | self.state = .connected(<#T##MatrixInterface#>) 48 | } catch { 49 | // Apparently it didn't work :( 50 | } 51 | } 52 | } 53 | */ 54 | -------------------------------------------------------------------------------- /Circles/Models/CirclesConfigV1.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CirclesConfig.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 3/30/23. 6 | // 7 | 8 | import Foundation 9 | import Matrix 10 | 11 | let EVENT_TYPE_CIRCLES_CONFIG_V1 = "org.futo.circles.config" 12 | 13 | struct CirclesConfigContentV1: Codable { 14 | var root: RoomId 15 | var circles: RoomId 16 | var groups: RoomId 17 | var galleries: RoomId 18 | var people: RoomId 19 | var profile: RoomId // aka Shared Circles 20 | } 21 | -------------------------------------------------------------------------------- /Circles/Models/CirclesConfigV2.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CirclesConfigV2.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/10/24. 6 | // 7 | 8 | import Foundation 9 | import Matrix 10 | 11 | let EVENT_TYPE_CIRCLES_CONFIG_V2 = "org.futo.circles.config.v2" 12 | 13 | struct CirclesConfigContentV2: Codable { 14 | var root: RoomId 15 | var groups: RoomId 16 | var galleries: RoomId 17 | var people: RoomId 18 | var profile: RoomId // aka Shared Circles 19 | var timelines: RoomId 20 | } 21 | -------------------------------------------------------------------------------- /Circles/Models/GalleryRoom.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GalleryRoom.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 3/23/23. 6 | // 7 | 8 | import Foundation 9 | import Matrix 10 | 11 | typealias GalleryRoom = Matrix.Room 12 | -------------------------------------------------------------------------------- /Circles/Models/GroupRoom.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GroupRoom.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 3/23/23. 6 | // 7 | 8 | import Foundation 9 | import Matrix 10 | 11 | typealias GroupRoom = Matrix.Room 12 | -------------------------------------------------------------------------------- /Circles/Models/PersonRoom.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersonRoom.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 3/23/23. 6 | // 7 | 8 | import Foundation 9 | import Matrix 10 | 11 | typealias PersonRoom = Matrix.SpaceRoom 12 | -------------------------------------------------------------------------------- /Circles/Models/PowerLevel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PowerLevel.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/9/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct PowerLevel: Identifiable, Equatable, Hashable { 11 | var power: Int 12 | 13 | var id: Int { 14 | power 15 | } 16 | 17 | var description: String { 18 | if power < 0 { 19 | return "Can View" 20 | } else if power < 50 { 21 | return "Can Post" 22 | } else if power < 100 { 23 | return "Moderator" 24 | } else { 25 | return "Admin" 26 | } 27 | } 28 | 29 | static func ==(lhs: PowerLevel, rhs: PowerLevel) -> Bool { 30 | lhs.power == rhs.power 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Circles/Models/ProfileSpace.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileSpace.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/31/23. 6 | // 7 | 8 | import Foundation 9 | import Matrix 10 | 11 | /* 12 | This is the type to use for *our* profile space room, 13 | where we know that we are a joined member of every child room. 14 | To represent other users' profile spaces, use PersonRoom instead. 15 | */ 16 | 17 | typealias ProfileSpace = ContainerRoom 18 | -------------------------------------------------------------------------------- /Circles/Models/Room+URL.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Room+URL.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 10/27/23. 6 | // 7 | 8 | import Foundation 9 | import Matrix 10 | 11 | // cvw: Putting this here rather than in Matrix.swift because these URLs are specific to Circles 12 | // For a generic Matrix chat client, you would probably want to use matrix.to instead, or the new matrix:// URL type 13 | extension Matrix.Room { 14 | var url: URL { 15 | 16 | func urlPrefix() -> String { 17 | switch self.type { 18 | case ROOM_TYPE_CIRCLE: 19 | return "timeline" 20 | case ROOM_TYPE_GROUP: 21 | return "group" 22 | case ROOM_TYPE_PHOTOS: 23 | return "gallery" 24 | case ROOM_TYPE_SPACE: 25 | return "profile" 26 | default: 27 | return "room" 28 | } 29 | } 30 | 31 | let prefix = urlPrefix() 32 | 33 | return URL(string: "https://\(CIRCLES_PRIMARY_DOMAIN)/\(prefix)/\(self.roomId)")! 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Circles/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Circles/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | 8 | NSPrivacyAccessedAPITypeReasons 9 | 10 | E174.1 11 | 12 | NSPrivacyAccessedAPIType 13 | NSPrivacyAccessedAPICategoryDiskSpace 14 | 15 | 16 | NSPrivacyAccessedAPITypeReasons 17 | 18 | C617.1 19 | 20 | NSPrivacyAccessedAPIType 21 | NSPrivacyAccessedAPICategoryFileTimestamp 22 | 23 | 24 | NSPrivacyAccessedAPIType 25 | NSPrivacyAccessedAPICategoryActiveKeyboards 26 | NSPrivacyAccessedAPITypeReasons 27 | 28 | 54BD.1 29 | 30 | 31 | 32 | NSPrivacyAccessedAPIType 33 | NSPrivacyAccessedAPICategoryUserDefaults 34 | NSPrivacyAccessedAPITypeReasons 35 | 36 | 1C8F.1 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /Circles/Utilities/Array+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CryptoSwift 3 | // 4 | // Copyright (C) 2014-2021 Marcin Krzyżanowski 5 | // This software is provided 'as-is', without any express or implied warranty. 6 | // 7 | // In no event will the authors be held liable for any damages arising from the use of this software. 8 | // 9 | // Permission is granted to anyone to use this software for any purpose,including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: 10 | // 11 | // - The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation is required. 12 | // - Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. 13 | // - This notice may not be removed or altered from any source or binary distribution. 14 | // 15 | 16 | // The following extensions were extracted from https://github.com/krzyzanowskim/CryptoSwift/blob/main/Sources/CryptoSwift/Array%2BExtension.swift 17 | 18 | 19 | extension Array { 20 | @inlinable 21 | init(reserveCapacity: Int) { 22 | self = Array() 23 | self.reserveCapacity(reserveCapacity) 24 | } 25 | 26 | @inlinable 27 | var slice: ArraySlice { 28 | self[self.startIndex ..< self.endIndex] 29 | } 30 | 31 | @inlinable 32 | subscript (safe index: Index) -> Element? { 33 | return indices.contains(index) ? self[index] : nil 34 | } 35 | } 36 | 37 | extension Array where Element == UInt8 { 38 | public init(hex: String) { 39 | self.init(reserveCapacity: hex.unicodeScalars.lazy.underestimatedCount) 40 | var buffer: UInt8? 41 | var skip = hex.hasPrefix("0x") ? 2 : 0 42 | for char in hex.unicodeScalars.lazy { 43 | guard skip == 0 else { 44 | skip -= 1 45 | continue 46 | } 47 | guard char.value >= 48 && char.value <= 102 else { 48 | removeAll() 49 | return 50 | } 51 | let v: UInt8 52 | let c: UInt8 = UInt8(char.value) 53 | switch c { 54 | case let c where c <= 57: 55 | v = c - 48 56 | case let c where c >= 65 && c <= 70: 57 | v = c - 55 58 | case let c where c >= 97: 59 | v = c - 87 60 | default: 61 | removeAll() 62 | return 63 | } 64 | if let b = buffer { 65 | append(b << 4 | v) 66 | buffer = nil 67 | } else { 68 | buffer = v 69 | } 70 | } 71 | if let b = buffer { 72 | append(b) 73 | } 74 | } 75 | 76 | public func toHexString() -> String { 77 | `lazy`.reduce(into: "") { 78 | var s = String($1, radix: 16) 79 | if s.count == 1 { 80 | s = "0" + s 81 | } 82 | $0 += s 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Circles/Utilities/Array+RawRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+RawRepresentable.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 11/10/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array: RawRepresentable where Element: Codable { 11 | // Tried to use Data but it doesn't work... Trying String 12 | public typealias RawValue = String 13 | 14 | public init?(rawValue: String) { 15 | let decoder = JSONDecoder() 16 | guard let data = rawValue.data(using: .utf8), 17 | let array = try? decoder.decode([Element].self, from: data) 18 | else { 19 | return nil 20 | } 21 | self = array 22 | } 23 | 24 | public var rawValue: String { 25 | let encoder = JSONEncoder() 26 | guard let data = try? encoder.encode(self), 27 | let string = String(data: data, encoding: .utf8) 28 | else { 29 | return "" 30 | } 31 | return string 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Circles/Utilities/Array+Sample.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2018, Ralf Ebert 2 | // Source https://www.ralfebert.de/ios-examples/swift/array-random-sample/ 3 | // License https://opensource.org/licenses/MIT 4 | // License https://creativecommons.org/publicdomain/zero/1.0/ 5 | 6 | import Foundation 7 | import Darwin 8 | 9 | extension Collection { 10 | 11 | /** 12 | * Returns a random element of the Array or nil if the Array is empty. 13 | */ 14 | var sample : Element? { 15 | guard !isEmpty else { return nil } 16 | let offset = arc4random_uniform(numericCast(self.count)) 17 | let idx = self.index(self.startIndex, offsetBy: numericCast(offset)) 18 | return self[idx] 19 | } 20 | 21 | /** 22 | * Returns `count` random elements from the array. 23 | * If there are not enough elements in the Array, a smaller Array is returned. 24 | * Elements will not be returned twice except when there are duplicate elements in the original Array. 25 | */ 26 | func sample(_ count : UInt) -> [Element] { 27 | let sampleCount = Swift.min(numericCast(count), self.count) 28 | 29 | var elements = Array(self) 30 | var samples : [Element] = [] 31 | 32 | while samples.count < sampleCount { 33 | let idx = (0..= 1 else { return } 50 | 51 | for i in (1..: View { 12 | var role: ButtonRole? 13 | var errorMessage: String? 14 | var action: () async throws -> Void 15 | @ViewBuilder var label: () -> Label 16 | 17 | @State private var pending = false 18 | 19 | func runAction() { 20 | pending = true 21 | 22 | Task { 23 | do { 24 | try await action() 25 | } catch { 26 | print("AsyncButton: Action failed") 27 | 28 | await ToastPresenter.shared.showToast(message: errorMessage ?? error.localizedDescription) 29 | } 30 | await MainActor.run { 31 | pending = false 32 | } 33 | } 34 | } 35 | 36 | var body: some View { 37 | Button( 38 | role: role, 39 | action: runAction, 40 | label: { 41 | label() 42 | } 43 | ) 44 | .disabled(pending) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Circles/Utilities/BCrypt/pycabcrypt.h: -------------------------------------------------------------------------------- 1 | #ifndef PYCABCRYPT 2 | #define PYCABCRYPT 3 | 4 | #include 5 | #include 6 | #include 7 | #include "portable_endian.h" 8 | 9 | #if defined(_WIN32) 10 | typedef unsigned char uint8_t; 11 | typedef uint8_t u_int8_t; 12 | typedef unsigned short uint16_t; 13 | typedef uint16_t u_int16_t; 14 | typedef unsigned uint32_t; 15 | typedef uint32_t u_int32_t; 16 | typedef unsigned long long uint64_t; 17 | typedef uint64_t u_int64_t; 18 | #define snprintf _snprintf 19 | #define __attribute__(unused) 20 | #elif defined(__sun) 21 | typedef uint8_t u_int8_t; 22 | typedef uint16_t u_int16_t; 23 | typedef uint32_t u_int32_t; 24 | typedef uint64_t u_int64_t; 25 | #else 26 | #include 27 | #endif 28 | 29 | #define explicit_bzero(s,n) memset(s, 0, n) 30 | #define DEF_WEAK(f) 31 | 32 | int bcrypt_hashpass(const char *key, const char *salt, char *encrypted, size_t encryptedlen); 33 | int encode_base64(char *, const u_int8_t *, size_t); 34 | int timingsafe_bcmp(const void *b1, const void *b2, size_t n); 35 | int bcrypt_pbkdf(const char *pass, size_t passlen, const uint8_t *salt, size_t saltlen, uint8_t *key, size_t keylen, unsigned int rounds); 36 | 37 | #endif 38 | 39 | 40 | -------------------------------------------------------------------------------- /Circles/Utilities/CustomFonts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomFonts.swift 3 | // Circles 4 | // 5 | // Created by Dmytro Ryshchuk on 7/25/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomFonts { 11 | static let inter14 = Font.custom("Inter", size: 14) 12 | static let nunito12 = Font.custom("Nunito", size: 12) 13 | static let nunito14 = Font.custom("Nunito", size: 14) 14 | static let nunito16 = Font.custom("Nunito", size: 16) 15 | static let nunito20 = Font.custom("Nunito", size: 20) 16 | static let nunito24 = Font.custom("Nunito", size: 24) 17 | static let outfit11 = Font.custom("Outfit", size: 11) 18 | static let outfit14 = Font.custom("Outfit", size: 14) 19 | } 20 | -------------------------------------------------------------------------------- /Circles/Utilities/Data+hex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Data+hex.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 3/6/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Data { 11 | var hexString: String { 12 | let string = self.map { 13 | String(format: "%02hhx", $0) 14 | }.joined() 15 | 16 | return string 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Circles/Utilities/DeleteAndPurge.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeleteAndPurge.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 2/8/24. 6 | // 7 | 8 | import Foundation 9 | import Matrix 10 | 11 | 12 | func deleteAndPurge(message: Matrix.Message) async throws { 13 | let content = message.content 14 | let session = message.room.session 15 | try await message.room.redact(eventId: message.eventId, 16 | reason: "Deleted by \(message.room.session.whoAmI())") 17 | // Now attempt to delete media associated with this event, if possible 18 | // Since the Matrix spec has no DELETE for media, this will probably fail, so don't worry if these calls throw errors 19 | if let messageContent = content as? Matrix.MessageContent { 20 | switch messageContent.msgtype { 21 | case M_IMAGE: 22 | if let imageContent = messageContent as? Matrix.mImageContent { 23 | if let file = imageContent.file { 24 | try? await session.deleteMedia(file.url) 25 | } 26 | if let url = imageContent.url { 27 | try? await session.deleteMedia(url) 28 | } 29 | if let thumbnail_file = imageContent.thumbnail_file { 30 | try? await session.deleteMedia(thumbnail_file.url) 31 | } 32 | if let thumbnail_url = imageContent.thumbnail_url { 33 | try? await session.deleteMedia(thumbnail_url) 34 | } 35 | } 36 | case M_VIDEO: 37 | if let videoContent = messageContent as? Matrix.mVideoContent { 38 | if let file = videoContent.file { 39 | try? await session.deleteMedia(file.url) 40 | } 41 | if let url = videoContent.url { 42 | try? await session.deleteMedia(url) 43 | } 44 | if let thumbnail_file = videoContent.thumbnail_file { 45 | try? await session.deleteMedia(thumbnail_file.url) 46 | } 47 | if let thumbnail_url = videoContent.thumbnail_url { 48 | try? await session.deleteMedia(thumbnail_url) 49 | } 50 | } 51 | default: 52 | print("Not deleting any media for msgtype \(messageContent.msgtype)") 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Circles/Utilities/HtmlView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HtmlView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 3/29/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import UIKit 11 | 12 | // Loosely based on https://stackoverflow.com/a/62281735 13 | 14 | struct HtmlView: UIViewRepresentable { 15 | let html: String 16 | 17 | func makeUIView(context: UIViewRepresentableContext) -> UILabel { 18 | let label = UILabel() 19 | label.lineBreakMode = .byWordWrapping 20 | label.numberOfLines = 0 21 | 22 | if let data = html.data(using: .utf8), 23 | let mutable = try? NSMutableAttributedString(data: data, 24 | options: [ 25 | .documentType: NSAttributedString.DocumentType.html, 26 | ], 27 | documentAttributes: nil) 28 | { 29 | //mutable.addAttribute(.font, value: UIFont.systemFont(ofSize: 24), range: NSRange(location: 0, length: mutable.length)) 30 | let attributedString = NSAttributedString(attributedString: mutable) 31 | DispatchQueue.main.async { 32 | label.attributedText = attributedString 33 | } 34 | } 35 | 36 | return label 37 | } 38 | 39 | func updateUIView(_ uiView: UILabel, context: Context) { 40 | // Do nothing 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Circles/Utilities/ImageSaver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageSaver.swift 3 | // Circles for iOS 4 | // 5 | // Created by Charles Wright on 11/11/20. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | // https://www.hackingwithswift.com/books/ios-swiftui/how-to-save-images-to-the-users-photo-library 12 | // https://www.hackingwithswift.com/read/13/5/saving-to-the-ios-photo-library 13 | 14 | class ImageSaver: NSObject { 15 | @IBAction func writeToPhotoAlbum(image: UIImage) { 16 | UIImageWriteToSavedPhotosAlbum(image, self, #selector(saveError(_:didFinishSavingWithError:contextInfo:)), nil) 17 | } 18 | 19 | @objc func saveError(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { 20 | if let _ = error { // let error 21 | print("Error: Save failed") 22 | } else { 23 | print("Save finished!") 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Circles/Utilities/LegacySecretStoreKeygen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LegacySecretStoreKeygen.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/24/23. 6 | // 7 | 8 | import Foundation 9 | import CryptoKit 10 | import Matrix 11 | 12 | 13 | func generateLegacySecretStorageKey(userId: UserId, password: String) throws -> (String, Data) { 14 | // Update 2021-06-16 - Adding my crazy scheme for doing 15 | // SSSS using only a single password 16 | // 17 | // First we bcrypt the password to get a secret that is 18 | // resistant to brute force and dictionary attack. 19 | // Then we use the symmetric ratchet to generate two keys 20 | // * ~~One for the login password~~ (This one we can now ignore) 21 | // * One for the secret "private key" for the recovery service (This is the secret storage key) 22 | 23 | let username = userId.username.trimmingCharacters(in: ["@"]) 24 | 25 | print("SECRETS\tExtracted username [\(username)] from given userId [\(userId)]") 26 | 27 | guard let data = username.data(using: .utf8) else { 28 | let msg = "Failed to convert username to data" 29 | print("SECRETS\t\(msg)") 30 | throw CirclesError(msg) 31 | } 32 | 33 | let saltDigest = SHA256.hash(data: data) 34 | let saltString = saltDigest 35 | .map { String(format: "%02hhx", $0) } 36 | .prefix(16) 37 | .joined() 38 | print("SECRETS\tComputed salt string = [\(saltString)]") 39 | 40 | let numRounds = 14 41 | guard let bcrypt = try? BCrypt.Hash(password, salt: "$2a$\(numRounds)$\(saltString)") else { 42 | let msg = "BCrypt KDF failed" 43 | print("SECRETS\t\(msg)") 44 | throw CirclesError(msg) 45 | } 46 | print("SECRETS\tGot bcrypt hash = [\(bcrypt)]") 47 | print(" \t 12345678901234567890123456789012345678901234567890") 48 | 49 | // Grabbing everything after the $ gives us the salt as well as the hash 50 | //let root = String(bcrypt.suffix(from: bcrypt.lastIndex(of: "$")!).dropFirst(1)) 51 | // Grabbing only the last 31 chars gives us just the hash 52 | let root = String(bcrypt.suffix(31)) 53 | print("SECRETS\tRoot secret = [\(root)] (\(root.count) chars)") 54 | 55 | /* 56 | let newLoginPassword = SHA256.hash(data: "LoginPassword|\(root)".data(using: .utf8)!) 57 | .prefix(16) 58 | .map { String(format: "%02hhx", $0) } 59 | .joined() 60 | print("SECRETS\tGot new login password = [\(newLoginPassword)]") 61 | */ 62 | 63 | let newPrivateKey = SHA256.hash(data: "S4Key|\(root)".data(using: .utf8)!) 64 | .withUnsafeBytes { 65 | Data(Array($0)) 66 | } 67 | print("SECRETS\tGot new private key = [\(newPrivateKey)]") 68 | 69 | 70 | //let keyId = try Matrix.SecretStore.computeKeyId(key: newPrivateKey) 71 | let keyId = UUID().uuidString 72 | 73 | return (keyId, newPrivateKey) 74 | } 75 | -------------------------------------------------------------------------------- /Circles/Utilities/QR.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QR.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 10/26/23. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | import AVFoundation 11 | import CoreImage 12 | import CoreImage.CIFilterBuiltins 13 | 14 | // A QR code of the room id 15 | func qrCode(url: URL) -> UIImage? { 16 | guard let data = url.absoluteString.data(using: .ascii) 17 | else { 18 | print("Failed to generate QR code: Couldn't get roomId as ASCII data") 19 | return nil 20 | } 21 | 22 | // Use the built-in CoreImage filter to create our QR code 23 | // https://developer.apple.com/documentation/coreimage/ciqrcodegenerator 24 | let filter = CIFilter.qrCodeGenerator() 25 | filter.setValue(data, forKey: "inputMessage") 26 | filter.setValue("Q", forKey: "inputCorrectionLevel") // 25% 27 | guard let result = filter.outputImage 28 | else { 29 | print("Failed to generate QR code: Couldn't get CIFilter output image") 30 | return nil 31 | } 32 | 33 | // Scale up the QR code by a factor of 10x 34 | let transform = CGAffineTransform(scaleX: 10, y: 10) 35 | let transformedImage = result.transformed(by: transform) 36 | 37 | // For whatever reason, we MUST convert to a CGImage here, using the CIContext. 38 | // If we do not do this (eg by trying to create a UIImage directly from the CIImage), 39 | // then we get nothing but a blank square for our QR code. :( 40 | let context = CIContext() 41 | guard let cgImg = context.createCGImage(transformedImage, from: transformedImage.extent) 42 | else { 43 | print("Failed to generate QR code: Failed to create CGImage from transformed image") 44 | return nil 45 | } 46 | 47 | let img = UIImage(cgImage: cgImg) 48 | print("QR code image is \(img.size.height) x \(img.size.width)") 49 | return img 50 | } 51 | -------------------------------------------------------------------------------- /Circles/Utilities/RelativeTimestampFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RelativeTimestampFormatter.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/16/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum RelativeTimestampFormatter { 11 | 12 | public static func format(date: Date) -> String { 13 | 14 | let interval = Date().timeIntervalSince(date) 15 | 16 | if interval < 60 { 17 | return "now" 18 | } else if interval < 3600 { 19 | let minutes = Int(interval) / 60 20 | return "\(minutes)m" 21 | } else if interval < 86400 { 22 | let hours = Int(interval) / 3600 23 | return "\(hours)h" 24 | } else if interval < 604800 { 25 | let days = Int(interval) / 86400 26 | return "\(days)d" 27 | } else if interval < 31449600 { 28 | let weeks = Int(interval) / 604800 29 | return "\(weeks)w" 30 | } else { 31 | let years = Int(interval) / 31449600 32 | return "\(years)y" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Circles/Utilities/SaveImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SaveImage.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 2/8/24. 6 | // 7 | 8 | import Foundation 9 | import Matrix 10 | 11 | func saveEncryptedImage(file: Matrix.mEncryptedFile, 12 | session: Matrix.Session 13 | ) async throws { 14 | 15 | guard let data = try? await session.downloadAndDecryptData(file), 16 | let image = UIImage(data: data) 17 | else { 18 | print("Failed to get image for encrypted URL \(file.url)") 19 | return 20 | } 21 | 22 | print("Saving image...") 23 | let imageSaver = ImageSaver() 24 | await imageSaver.writeToPhotoAlbum(image: image) 25 | print("Successfully saved image from \(file.url)") 26 | } 27 | 28 | func savePlaintextImage(url: MXC, 29 | session: Matrix.Session 30 | ) async throws { 31 | print("Trying to save image from URL \(url)") 32 | guard let data = try? await session.downloadData(mxc: url), 33 | let image = UIImage(data: data) 34 | else { 35 | print("Failed to get image for url \(url)") 36 | return 37 | } 38 | 39 | print("Saving image...") 40 | let imageSaver = ImageSaver() 41 | await imageSaver.writeToPhotoAlbum(image: image) 42 | print("Successfully saved image from \(url)") 43 | } 44 | 45 | func saveImage(content: Matrix.mImageContent, 46 | session: Matrix.Session 47 | ) async throws { 48 | // Coming in, we have no idea what this m.image content may contain 49 | // It may have any mix of encrypted / unencrypted full-res image and thumbnail 50 | // So we try to be a little bit smart 51 | // - We prefer the full-res image over the thumbnail 52 | // - When trying to find an image (either full-res or thumbnail) we prefer the encrypted version over unencrypted 53 | // In other words, our preferences are: 54 | // 1. Full-res, encrypted 55 | // 2. Full-res, non encrypted 56 | // 3. Thumbnail, encrypted 57 | // 4. Thumbnail, non encrypted 58 | 59 | if let fullResFile = content.file { 60 | try await saveEncryptedImage(file: fullResFile, session: session) 61 | } 62 | else if let fullResUrl = content.url { 63 | try await savePlaintextImage(url: fullResUrl, session: session) 64 | } 65 | else if let thumbnailFile = content.thumbnail_file { 66 | try await saveEncryptedImage(file: thumbnailFile, session: session) 67 | } 68 | else if let thumbnailUrl = content.thumbnail_url { 69 | try await savePlaintextImage(url: thumbnailUrl, session: session) 70 | } 71 | else { 72 | print("Error: Can't save image -- No encrypted file or URL") 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Circles/Utilities/SecureFieldWithEye.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EyeSecureField.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 8/2/21. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | 11 | struct SecureFieldWithEye: View { 12 | @Binding 13 | var password: String 14 | var isNewPassword: Bool = false 15 | var placeholder: String = "Password" 16 | var height: CGFloat = 40 17 | 18 | @State 19 | private var showText: Bool = false 20 | 21 | private enum Focus { 22 | case secure, text 23 | } 24 | 25 | @FocusState 26 | private var focus: Focus? 27 | 28 | @Environment(\.scenePhase) 29 | private var scenePhase 30 | 31 | var body: some View { 32 | HStack { 33 | ZStack { 34 | SecureField(placeholder, text: $password) 35 | .frame(height: height) 36 | .padding([.horizontal], 12) 37 | .background(Color.background) 38 | .cornerRadius(10) 39 | .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.greyCool400)) 40 | .focused($focus, equals: .secure) 41 | .opacity(showText ? 0 : 1) 42 | .textContentType(isNewPassword ? .newPassword : .password) 43 | 44 | TextField(placeholder, text: $password) 45 | .frame(height: height) 46 | .padding([.horizontal], 12) 47 | .background(Color.background) 48 | .cornerRadius(10) 49 | .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.greyCool400)) 50 | .focused($focus, equals: .text) 51 | .opacity(showText ? 1 : 0) 52 | .textContentType(isNewPassword ? .newPassword : .password) 53 | } 54 | 55 | Button(action: { 56 | showText.toggle() 57 | }) { 58 | Image(systemName: showText ? "eye.slash.fill" : "eye.fill") 59 | .foregroundColor(showText ? .gray : .blue) 60 | } 61 | } 62 | .onChange(of: focus) { newValue in 63 | if newValue != nil { 64 | focus = showText ? .text : .secure 65 | } 66 | } 67 | .onChange(of: scenePhase) { newValue in 68 | if newValue != .active { 69 | showText = false 70 | } 71 | } 72 | .onChange(of: showText) { newValue in 73 | if focus != nil { 74 | DispatchQueue.main.async { 75 | focus = newValue ? .text : .secure 76 | } 77 | } 78 | } 79 | } 80 | } 81 | 82 | /* 83 | struct EyeSecureField_Previews: PreviewProvider { 84 | static var previews: some View { 85 | EyeSecureField() 86 | } 87 | } 88 | */ 89 | -------------------------------------------------------------------------------- /Circles/Utilities/Sequence+asyncMap.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sequence+asyncMap.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 3/14/23. 6 | // 7 | 8 | import Foundation 9 | 10 | // Based on https://www.swiftbysundell.com/articles/async-and-concurrent-forEach-and-map/ 11 | extension Sequence { 12 | func map( 13 | _ transform: (Element) async throws -> T 14 | ) async rethrows -> [T] { 15 | var values = [T]() 16 | 17 | for element in self { 18 | try await values.append(transform(element)) 19 | } 20 | 21 | return values 22 | } 23 | 24 | func compactMap( 25 | _ transform: (Element) async throws -> T? 26 | ) async rethrows -> [T] { 27 | var values = [T]() 28 | 29 | for element in self { 30 | let t = try await transform(element) 31 | if t != nil { 32 | values.append(t!) 33 | } 34 | } 35 | 36 | return values 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Circles/Utilities/ToastPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToastPresenter.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 6/7/24. 6 | // 7 | 8 | import Foundation 9 | import JDStatusBarNotification 10 | 11 | public struct CustomToastSetup { 12 | var image: UIImage? = nil 13 | var titleFont: UIFont? = UIFont.systemFont(ofSize: 12, weight: .medium) 14 | var subtitleFont: UIFont? = UIFont.systemFont(ofSize: 10) 15 | var titleColor: UIColor = .gray 16 | var subtitleColor: UIColor = .systemGray 17 | var backgroundColor: UIColor = .systemBackground 18 | } 19 | 20 | public class ToastPresenter { 21 | public static var shared = ToastPresenter() 22 | public var delay = 3.0 23 | 24 | @MainActor 25 | public func showToast(message: String, 26 | subtitle: String? = nil, 27 | customToast: CustomToastSetup? = nil) async { 28 | setupToast(with: message, subtitle: subtitle, customToast: customToast) 29 | } 30 | 31 | public func showToast(message: String, 32 | subtitle: String? = nil, 33 | customToast: CustomToastSetup? = nil) { 34 | setupToast(with: message, subtitle: subtitle, customToast: customToast) 35 | } 36 | 37 | private func setupToast(with message: String, 38 | subtitle: String? = nil, 39 | customToast: CustomToastSetup? = nil) { 40 | NotificationPresenter.shared.updateDefaultStyle { style in 41 | if let customToast { 42 | style.backgroundStyle.backgroundColor = customToast.backgroundColor 43 | style.textStyle.textColor = customToast.titleColor 44 | style.textStyle.font = customToast.titleFont 45 | style.subtitleStyle.textColor = customToast.subtitleColor 46 | style.subtitleStyle.font = customToast.subtitleFont 47 | } 48 | 49 | return style 50 | } 51 | 52 | if let subtitle { 53 | NotificationPresenter.shared.present(message, subtitle: subtitle) 54 | } else { 55 | NotificationPresenter.shared.present(message) 56 | } 57 | 58 | if let toastImage = customToast?.image { 59 | let image = UIImageView(image: toastImage) 60 | NotificationPresenter.shared.displayLeftView(image) 61 | } 62 | NotificationPresenter.shared.dismiss(animated: true, after: delay) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Circles/Utilities/Utils.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020, 2021 Kombucha Digital Privacy Systems LLC 2 | // 3 | // Utils.swift 4 | // Circles for iOS 5 | // 6 | // Created by Charles Wright on 10/28/20. 7 | // 8 | 9 | // swiftlint:disable identifier_name 10 | 11 | import Foundation 12 | import UIKit 13 | 14 | func downscale_image(from image: UIImage, to maxSize: CGSize) -> UIImage? { 15 | let height = image.size.height 16 | let width = image.size.width 17 | let MAX_HEIGHT = maxSize.height 18 | let MAX_WIDTH = maxSize.width 19 | print("DOWNSCALE\t h = \(height)\t w = \(width)") 20 | print("DOWNSCALE\t max h = \(MAX_HEIGHT)\t max w = \(MAX_WIDTH)") 21 | 22 | if height > MAX_HEIGHT || width > MAX_WIDTH { 23 | let aspectRatio = image.size.width / image.size.height 24 | print("DOWNSCALE\tAspect ratio = \(aspectRatio)") 25 | let scale = aspectRatio > 1 26 | ? MAX_WIDTH / image.size.width 27 | : MAX_HEIGHT / image.size.height 28 | print("DOWNSCALE\tScale = \(scale)") 29 | let newSize = CGSize(width: scale*image.size.width, height: scale*image.size.height) 30 | print("DOWNSCALE\tNew size = \(newSize)") 31 | let renderer = UIGraphicsImageRenderer(size: newSize) 32 | return renderer.image { (context) in 33 | image.draw(in: CGRect(origin: .zero, size: newSize)) 34 | } 35 | } 36 | return image 37 | } 38 | 39 | func b64decode(_ str: String) -> [UInt8]? { 40 | guard let data = Data(base64Encoded: str) else { 41 | return nil 42 | } 43 | let array = [UInt8](data) 44 | return array 45 | } 46 | 47 | func abbreviate(_ input: String?, textIfEmpty: String = "(none)") -> String { 48 | guard let string = input 49 | else { 50 | return textIfEmpty 51 | } 52 | 53 | if string.count < 23 { 54 | return string 55 | } else { 56 | return String("\(string.prefix(20))...") 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Circles/Utilities/zxcvbn/DBMatcher.h: -------------------------------------------------------------------------------- 1 | // 2 | // DBMatcher.h 3 | // Zxcvbn 4 | // 5 | // Created by Leah Culver on 2/9/14. 6 | // Copyright (c) 2014 Dropbox. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface DBMatcher : NSObject 12 | 13 | @property (nonatomic, assign) NSUInteger keyboardAverageDegree; 14 | @property (nonatomic, assign) NSUInteger keypadAverageDegree; 15 | @property (nonatomic, assign) NSUInteger keyboardStartingPositions; 16 | @property (nonatomic, assign) NSUInteger keypadStartingPositions; 17 | 18 | - (NSArray *)omnimatch:(NSString *)password userInputs:(NSArray *)userInputs; 19 | 20 | @end 21 | 22 | @interface DBMatchResources : NSObject 23 | 24 | @property (nonatomic, strong) NSArray *dictionaryMatchers; 25 | @property (nonatomic, strong) NSDictionary *graphs; 26 | 27 | + (DBMatchResources *)sharedDBMatcherResources; 28 | 29 | @end 30 | 31 | @interface DBMatch : NSObject 32 | 33 | @property (nonatomic, assign) NSString *pattern; 34 | @property (strong, nonatomic) NSString *token; 35 | @property (nonatomic, assign) NSUInteger i; 36 | @property (nonatomic, assign) NSUInteger j; 37 | @property (nonatomic, assign) float entropy; 38 | @property (nonatomic, assign) int cardinality; 39 | 40 | // Dictionary 41 | @property (strong, nonatomic) NSString *matchedWord; 42 | @property (strong, nonatomic) NSString *dictionaryName; 43 | @property (nonatomic, assign) int rank; 44 | @property (nonatomic, assign) float baseEntropy; 45 | @property (nonatomic, assign) float upperCaseEntropy; 46 | 47 | // l33t 48 | @property (nonatomic, assign) BOOL l33t; 49 | @property (strong, nonatomic) NSDictionary *sub; 50 | @property (strong, nonatomic) NSString *subDisplay; 51 | @property (nonatomic, assign) int l33tEntropy; 52 | 53 | // Spatial 54 | @property (strong, nonatomic) NSString *graph; 55 | @property (nonatomic, assign) int turns; 56 | @property (nonatomic, assign) int shiftedCount; 57 | 58 | // Repeat 59 | @property (strong, nonatomic) NSString *repeatedChar; 60 | 61 | // Sequence 62 | @property (strong, nonatomic) NSString *sequenceName; 63 | @property (nonatomic, assign) int sequenceSpace; 64 | @property (nonatomic, assign) BOOL ascending; 65 | 66 | // Date 67 | @property (nonatomic, assign) int day; 68 | @property (nonatomic, assign) int month; 69 | @property (nonatomic, assign) int year; 70 | @property (strong, nonatomic) NSString *separator; 71 | 72 | @end -------------------------------------------------------------------------------- /Circles/Utilities/zxcvbn/DBPasswordStrengthMeterView.h: -------------------------------------------------------------------------------- 1 | // 2 | // DBPasswordStrengthMeterView.h 3 | // Zxcvbn 4 | // 5 | // Created by Leah Culver on 2/22/14. 6 | // Copyright (c) 2014 Dropbox. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @protocol DBPasswordStrengthMeterViewDelegate; 12 | 13 | @interface DBPasswordStrengthMeterView : UIView 14 | 15 | @property (nonatomic, assign) id delegate; 16 | 17 | - (void)setLightColor:(UIColor *)lightColor darkColor:(UIColor *)darkColor; 18 | - (void)scorePassword:(NSString *)password; 19 | - (void)scorePassword:(NSString *)password userInputs:(NSArray *)userInputs; 20 | 21 | @end 22 | 23 | @protocol DBPasswordStrengthMeterViewDelegate 24 | 25 | - (void)passwordStrengthMeterViewTapped:(DBPasswordStrengthMeterView *)passwordStrengthMeterView; 26 | 27 | @end 28 | -------------------------------------------------------------------------------- /Circles/Utilities/zxcvbn/DBScorer.h: -------------------------------------------------------------------------------- 1 | // 2 | // DBScorer.h 3 | // Zxcvbn 4 | // 5 | // Created by Leah Culver on 2/9/14. 6 | // Copyright (c) 2014 Dropbox. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @class DBResult; 12 | 13 | @interface DBScorer : NSObject 14 | 15 | - (DBResult *)minimumEntropyMatchSequence:(NSString *)password matches:(NSArray *)matches; 16 | 17 | @end 18 | 19 | 20 | @interface DBResult : NSObject 21 | 22 | @property (strong, nonatomic) NSString *password; 23 | @property (strong, nonatomic) NSString *entropy; // bits 24 | @property (strong, nonatomic) NSString *crackTime; // estimation of actual crack time, in seconds. 25 | @property (strong, nonatomic) NSString *crackTimeDisplay; // same crack time, as a friendlier string: "instant", "6 minutes", "centuries", etc. 26 | @property (nonatomic, assign) int score; // [0,1,2,3,4] if crack time is less than [10**2, 10**4, 10**6, 10**8, Infinity]. (useful for implementing a strength bar.) 27 | @property (strong, nonatomic) NSArray *matchSequence; // the list of patterns that zxcvbn based the entropy calculation on. 28 | @property (nonatomic, assign) float calcTime; // how long it took to calculate an answer, in milliseconds. usually only a few ms. 29 | 30 | @end -------------------------------------------------------------------------------- /Circles/Utilities/zxcvbn/DBZxcvbn.h: -------------------------------------------------------------------------------- 1 | // 2 | // DBZxcvbn.h 3 | // Zxcvbn 4 | // 5 | // Created by Leah Culver on 2/9/14. 6 | // Copyright (c) 2014 Dropbox. All rights reserved. 7 | // 8 | 9 | #import "DBMatcher.h" 10 | #import "DBScorer.h" 11 | 12 | @interface DBZxcvbn : NSObject 13 | 14 | - (DBResult *)passwordStrength:(NSString *)password; 15 | - (DBResult *)passwordStrength:(NSString *)password userInputs:(NSArray *)userInputs; 16 | 17 | @end 18 | -------------------------------------------------------------------------------- /Circles/Utilities/zxcvbn/DBZxcvbn.m: -------------------------------------------------------------------------------- 1 | // 2 | // DBZxcvbn.m 3 | // Zxcvbn 4 | // 5 | // Created by Leah Culver on 2/9/14. 6 | // Copyright (c) 2014 Dropbox. All rights reserved. 7 | // 8 | 9 | #import "DBZxcvbn.h" 10 | #import 11 | 12 | @interface DBZxcvbn () 13 | 14 | @property (nonatomic, strong) DBMatcher *matcher; 15 | @property (nonatomic, strong) DBScorer *scorer; 16 | 17 | @end 18 | 19 | @implementation DBZxcvbn 20 | 21 | - (id)init 22 | { 23 | self = [super init]; 24 | 25 | if (self != nil) { 26 | self.matcher = [[DBMatcher alloc] init]; 27 | self.scorer = [[DBScorer alloc] init]; 28 | } 29 | 30 | return self; 31 | } 32 | 33 | - (DBResult *)passwordStrength:(NSString *)password 34 | { 35 | return [self passwordStrength:password userInputs:nil]; 36 | } 37 | 38 | - (DBResult *)passwordStrength:(NSString *)password userInputs:(NSArray *)userInputs 39 | { 40 | CFTimeInterval start = CACurrentMediaTime(); 41 | NSArray *matches = [self.matcher omnimatch:password userInputs:userInputs]; 42 | DBResult *result = [self.scorer minimumEntropyMatchSequence:password matches:matches]; 43 | CFTimeInterval end = CACurrentMediaTime(); 44 | result.calcTime = (end - start) * 1000.0; 45 | 46 | return result; 47 | } 48 | 49 | @end 50 | -------------------------------------------------------------------------------- /Circles/Utilities/zxcvbn/Zxcvbn.h: -------------------------------------------------------------------------------- 1 | // 2 | // Zxcvbn.h 3 | // Zxcvbn 4 | // 5 | // Created by Leah Culver on 26 Oct 2015. 6 | // Copyright © 2015 Dropbox. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for Zxcvbn. 12 | FOUNDATION_EXPORT double ZxcvbnVersionNumber; 13 | 14 | //! Project version string for Zxcvbn. 15 | FOUNDATION_EXPORT const unsigned char ZxcvbnVersionString[]; 16 | 17 | #import "DBMatcher.h" 18 | #import "DBScorer.h" 19 | #import "DBZxcvbn.h" 20 | #import "DBPasswordStrengthMeterView.h" 21 | -------------------------------------------------------------------------------- /Circles/Views/Circles/CircleFollowingRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircleFollowingRow.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 11/7/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | // This shows a row in a list representing a friend's timeline that we are following in one of our circles 12 | // This needs to be its own type so that it can keep local state `showConfirmUnfollow` 13 | // We wouldn't need this if the SwiftUI `.confirmationDialog` actually used its `presenting:` argument properly -- then we could just put the confirmation dialog in the parent view -- but oh well 14 | struct CircleFollowingRow: View { 15 | var container: ContainerRoom 16 | @ObservedObject var room: Matrix.Room 17 | @ObservedObject var user: Matrix.User 18 | 19 | var body: some View { 20 | NavigationLink(destination: FollowingTimelineDetailsView(room: room, user: user, container: container)) { 21 | RoomAvatarView(room: room, avatarText: .none) 22 | .frame(width: 40, height: 40) 23 | 24 | VStack(alignment: .leading) { 25 | Text("\(user.displayName ?? user.userId.username)") 26 | if DebugModel.shared.debugMode { 27 | Text(room.roomId.stringValue) 28 | .font(.subheadline) 29 | .foregroundColor(.red) 30 | } 31 | let followerCount = max(0, room.joinedMembers.count-1) 32 | let unit = followerCount > 1 ? "followers" : "follower" 33 | Text("\(room.name ?? "(???)") (\(followerCount) \(unit))") 34 | } 35 | .onAppear { 36 | user.refreshProfile() 37 | } 38 | 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Circles/Views/Circles/CircleInvitationsIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircleInvitationsIndicator.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/19/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct CircleInvitationsIndicator: View { 12 | //@Binding var invitations: [Matrix.InvitedRoom] 13 | @ObservedObject var session: Matrix.Session 14 | @ObservedObject var container: ContainerRoom 15 | 16 | var body: some View { 17 | VStack { 18 | let invitations = session.invitations.values.filter { $0.type == ROOM_TYPE_CIRCLE } 19 | 20 | if invitations.count > 0 { 21 | 22 | NavigationLink(destination: CircleInvitationsView(session: session, container: container)) { 23 | HStack { 24 | Spacer() 25 | Label("You have \(invitations.count) pending invitation(s)", systemImage: "star") 26 | .fontWeight(.bold) 27 | .padding() 28 | Spacer() 29 | Image(systemName: SystemImages.chevronRight.rawValue) 30 | .font(.system(size: 24)) 31 | .padding() 32 | } 33 | .foregroundColor(.white) 34 | .background(Color.accentColor) 35 | .frame(maxHeight: 60) 36 | } 37 | 38 | } 39 | if DebugModel.shared.debugMode { 40 | Text("Debug: \(invitations.count) invitations here; \(session.invitations.count) total in the session") 41 | .foregroundColor(.red) 42 | .padding() 43 | } 44 | } 45 | 46 | } 47 | } 48 | 49 | /* 50 | struct CircleInvitationsIndicator_Previews: PreviewProvider { 51 | static var previews: some View { 52 | CircleInvitationsIndicator() 53 | } 54 | } 55 | */ 56 | -------------------------------------------------------------------------------- /Circles/Views/Circles/CircleInvitationsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircleInvitationsView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/19/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct CircleInvitationsView: View { 12 | //@Binding var invitations: [Matrix.InvitedRoom] 13 | @ObservedObject var session: Matrix.Session 14 | var container: ContainerRoom 15 | 16 | var body: some View { 17 | ScrollView { 18 | VStack(alignment: .leading, spacing: 10) { 19 | let invitations = session.invitations.values.filter { $0.type == ROOM_TYPE_CIRCLE } 20 | if invitations.isEmpty { 21 | Text("No pending invitations") 22 | } else { 23 | ForEach(invitations) { room in 24 | let user = room.session.getUser(userId: room.sender) 25 | InvitedCircleCard(room: room, user: user, container: container) 26 | .frame(maxWidth: 350) 27 | 28 | Divider() 29 | } 30 | } 31 | } 32 | .padding() 33 | } 34 | .navigationTitle(Text("Circle Invitations")) 35 | } 36 | } 37 | 38 | /* 39 | struct CircleInvitationsView_Previews: PreviewProvider { 40 | static var previews: some View { 41 | CircleInvitationsView() 42 | } 43 | } 44 | */ 45 | -------------------------------------------------------------------------------- /Circles/Views/Circles/CirclePicker.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020, 2021 Kombucha Digital Privacy Systems LLC 2 | // 3 | // CirclePicker.swift 4 | // Circles for iOS 5 | // 6 | // Created by Charles Wright on 11/10/20. 7 | // 8 | 9 | import SwiftUI 10 | import Matrix 11 | 12 | struct CirclePicker: View { 13 | //@ObservedObject var container: ContainerRoom 14 | @EnvironmentObject var appSession: CirclesApplicationSession 15 | @Binding var selected: Set 16 | 17 | var body: some View { 18 | ScrollView { 19 | VStack(spacing: 5) { 20 | let container = appSession.timelines 21 | //List { 22 | let rooms = container.rooms.values 23 | .filter({$0.creator == container.session.creds.userId}) 24 | .sorted { $0.timestamp < $1.timestamp } 25 | 26 | ForEach(rooms) { circle in 27 | //Text(circle.roomId.stringValue) 28 | Button(action: { 29 | if selected.contains(circle) { 30 | selected.remove(circle) 31 | } 32 | else { 33 | selected.insert(circle) 34 | } 35 | }) { 36 | HStack { 37 | Image(systemName: selected.contains(circle) ? SystemImages.checkmarkCircle.rawValue : SystemImages.circle.rawValue) 38 | .resizable() 39 | .frame(width: 25, height: 25) 40 | .foregroundColor(.gray) 41 | 42 | RoomAvatarView(room: circle, avatarText: .none) 43 | .clipShape(Circle()) 44 | .frame(width: 50, height: 50) 45 | Text(circle.name ?? "unnamed") 46 | //.fontWeight(.bold) 47 | Spacer() 48 | } 49 | .padding() 50 | } 51 | .buttonStyle(.plain) 52 | } 53 | .padding(.horizontal) 54 | } 55 | } 56 | } 57 | } 58 | 59 | /* 60 | struct StreamPicker_Previews: PreviewProvider { 61 | static var previews: some View { 62 | StreamPicker() 63 | } 64 | } 65 | */ 66 | -------------------------------------------------------------------------------- /Circles/Views/Circles/CircleRenameView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircleRenameView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/15/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct CircleRenameView: View { 12 | @ObservedObject var room: Matrix.Room 13 | @Environment(\.presentationMode) var presentation 14 | 15 | @State var newName: String 16 | 17 | enum FocusField { 18 | case circleName 19 | } 20 | @FocusState var focus: FocusField? 21 | 22 | init(room: Matrix.Room) { 23 | self.room = room 24 | self._newName = State(wrappedValue: room.name ?? "") 25 | } 26 | 27 | var body: some View { 28 | VStack(alignment: .center, spacing: 100) { 29 | Spacer() 30 | 31 | HStack { 32 | TextField(room.name ?? "", text: $newName, prompt: Text("New name")) 33 | .textFieldStyle(.roundedBorder) 34 | .textInputAutocapitalization(.words) 35 | .focused($focus, equals: .circleName) 36 | .frame(width: 300, height: 40) 37 | .onAppear { 38 | self.focus = .circleName 39 | } 40 | Button(action: { 41 | self.newName = "" 42 | }) { 43 | Image(systemName: SystemImages.xmark.rawValue) 44 | .foregroundColor(.gray) 45 | } 46 | } 47 | 48 | AsyncButton(action: { 49 | try await room.setName(newName: newName) 50 | self.presentation.wrappedValue.dismiss() 51 | }) { 52 | Text("Update") 53 | } 54 | 55 | Spacer() 56 | } 57 | .navigationTitle("Rename \(room.name ?? "circle")") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Circles/Views/Circles/UnifiedTimelineComposerSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnifiedTimelineComposerSheet.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/12/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct UnifiedTimelineComposerSheet: View { 12 | @ObservedObject var timelines: TimelineSpace 13 | @State var selectedRoom: Matrix.Room? 14 | @Environment(\.presentationMode) var presentation 15 | @State var createMyFirstCircle = false 16 | 17 | var body: some View { 18 | let circles = timelines.circles 19 | let me = timelines.session.me 20 | 21 | if let room = selectedRoom { 22 | PostComposer(room: room) 23 | } else if circles.count == 1, 24 | let onlyCircle = circles.first 25 | { 26 | PostComposer(room: onlyCircle) 27 | } else if circles.count > 1 { 28 | VStack(alignment: .center) { 29 | Spacer() 30 | 31 | Text("You have \(circles.count) circles. Which one would you like to share with?") 32 | .padding() 33 | 34 | ScrollView { 35 | VStack(alignment: .leading) { 36 | ForEach(circles) { circle in 37 | Button(action: { 38 | self.selectedRoom = circle 39 | }) { 40 | TimelineOverviewCard(room: circle, user: me) 41 | } 42 | .buttonStyle(.plain) 43 | } 44 | } 45 | } 46 | .frame(maxWidth: 400) 47 | 48 | Spacer() 49 | 50 | Button(role: .destructive, action: { 51 | self.presentation.wrappedValue.dismiss() 52 | }) { 53 | Text("Cancel") 54 | } 55 | .padding() 56 | } 57 | } else if createMyFirstCircle { 58 | CircleCreationSheet(container: timelines) 59 | } else { 60 | VStack(alignment: .center) { 61 | Text("It looks like you don't have any circles yet. Would you like to create one now?") 62 | 63 | Button(action: { 64 | self.createMyFirstCircle = true 65 | }) { 66 | Text("Create my first circle") 67 | } 68 | 69 | Button(role: .destructive, action: { 70 | self.presentation.wrappedValue.dismiss() 71 | }) { 72 | Text("Cancel") 73 | } 74 | .padding() 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Circles/Views/Circles/UnifiedTimelineSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnifiedTimelineSettingsView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/11/24. 6 | // 7 | 8 | import SwiftUI 9 | import PhotosUI 10 | import Matrix 11 | 12 | struct UnifiedTimelineSettingsView: View { 13 | @ObservedObject var space: TimelineSpace 14 | 15 | @State var showConfirmUnfollow = false 16 | @State var roomToUnfollow: Matrix.Room? 17 | 18 | @State var showInviteSheet = false 19 | @State var showShareSheet = false 20 | 21 | @State var showConfirmResend = false 22 | @State var showConfirmCancelInvite = false 23 | 24 | @ViewBuilder 25 | var followingSection: some View { 26 | let timelines = space.following 27 | if timelines.count > 0 { 28 | Section("Timelines I'm Following (\(timelines.count))") { 29 | ForEach(timelines) { room in 30 | let user = space.session.getUser(userId: room.creator) 31 | CircleFollowingRow(container: space, room: room, user: user) 32 | } 33 | } 34 | } 35 | } 36 | 37 | @ViewBuilder 38 | var circlesSection: some View { 39 | let circles = space.circles 40 | if circles.count > 0 { 41 | Section("My Circles") { 42 | ForEach(circles) { circle in 43 | /* 44 | let name = circle.name ?? "" 45 | let followerCount = Int.max(circle.joinedMembers.count - 1, 0) 46 | Text("\(name) (\(followerCount) followers)") 47 | */ 48 | if let name = circle.name { 49 | NavigationLink(destination: SingleTimelineSettingsView(room: circle)) { 50 | HStack { 51 | RoomAvatarView(room: circle, avatarText: .none) 52 | .frame(width: 40, height: 40) 53 | Text(name) 54 | } 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } 61 | 62 | var body: some View { 63 | VStack { 64 | Form { 65 | circlesSection 66 | followingSection 67 | } 68 | } 69 | .navigationTitle("Circles Settings") 70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /Circles/Views/Circles/UnifiedTimelineView.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020, 2021 Kombucha Digital Privacy Systems LLC 2 | // 3 | // CircleTimelineScreen.swift 4 | // Circles for iOS 5 | // 6 | // Created by Charles Wright on 12/17/20. 7 | // 8 | 9 | import SwiftUI 10 | import PhotosUI 11 | import Matrix 12 | 13 | struct UnifiedTimelineView: View { 14 | @ObservedObject var space: TimelineSpace 15 | @Environment(\.presentationMode) var presentation 16 | 17 | //@State var showComposer = false 18 | @State var showPhotosPicker: Bool = false 19 | @State var selectedItem: PhotosPickerItem? 20 | @State var showNewPostInSheetStyle = false 21 | //@State var image: UIImage? 22 | 23 | @State var viewModel = TimelineViewModel() 24 | 25 | var toolbarMenu: some View { 26 | NavigationLink(destination: UnifiedTimelineSettingsView(space: space)){ 27 | Label("Settings", systemImage: SystemImages.gearshapeFill.rawValue) 28 | } 29 | } 30 | 31 | var stupidSwiftUiTrick: Int { 32 | print("DEBUGUI\tStreamTimeline rendering for Circle \(space.roomId)") 33 | return 0 34 | } 35 | 36 | var body: some View { 37 | NavigationStack { 38 | ZStack { 39 | let _ = self.stupidSwiftUiTrick // foo 40 | 41 | UnifiedTimeline(space: space) 42 | .navigationBarTitle("All Posts", displayMode: .inline) 43 | .toolbar { 44 | ToolbarItemGroup(placement: .automatic) { 45 | toolbarMenu 46 | } 47 | } 48 | .onAppear { 49 | print("DEBUGUI\tStreamTimeline appeared for Circle \(space.roomId)") 50 | } 51 | .onDisappear { 52 | print("DEBUGUI\tStreamTimeline disappeared for Circle \(space.roomId)") 53 | } 54 | .sheet(isPresented: $showNewPostInSheetStyle) { 55 | UnifiedTimelineComposerSheet(timelines: space) 56 | } 57 | 58 | let circles = space.circles 59 | if circles.count > 0 { 60 | VStack { 61 | Spacer() 62 | HStack { 63 | Spacer() 64 | Button(action: { 65 | showNewPostInSheetStyle = true 66 | }) { 67 | Label("New post", systemImage: SystemImages.plusCircleFill.rawValue) 68 | } 69 | .buttonStyle(PillButtonStyle()) 70 | .padding(10) 71 | } 72 | } 73 | } 74 | } 75 | } 76 | .environmentObject(viewModel) 77 | } 78 | } 79 | 80 | -------------------------------------------------------------------------------- /Circles/Views/CirclesLogoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CirclesLogoView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 11/10/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CirclesLogoView: View { 11 | @Environment(\.colorScheme) var colorScheme 12 | 13 | var body: some View { 14 | let imageName = colorScheme == .dark ? "circles-logo-dark" : "circles-logo-light" 15 | BasicImage(name: imageName) 16 | } 17 | } 18 | 19 | #Preview { 20 | CirclesLogoView() 21 | } 22 | -------------------------------------------------------------------------------- /Circles/Views/Composer/ImagePicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImagePicker.swift 3 | // Circles for iOS 4 | // 5 | // Created by Charles Wright on 7/22/20. 6 | // 7 | 8 | import UIKit 9 | import SwiftUI 10 | 11 | // Apple has deprecated this approach, so we must switch to the new `PhotosPicker` 12 | 13 | // Inspired by https://www.appcoda.com/swiftui-camera-photo-library/ 14 | 15 | struct ImagePicker: UIViewControllerRepresentable { 16 | func makeCoordinator() -> Coordinator { 17 | Coordinator(self) 18 | } 19 | 20 | //@Binding var selectedImage: UIImage? 21 | @Environment(\.presentationMode) private var presentationMode 22 | 23 | var sourceType: UIImagePickerController.SourceType = .photoLibrary 24 | var allowEditing = false 25 | 26 | var completion: (UIImage?) -> Void = { _ in } 27 | 28 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { 29 | 30 | let imagePicker = UIImagePickerController() 31 | //imagePicker.allowsEditing = false 32 | imagePicker.allowsEditing = allowEditing 33 | imagePicker.sourceType = sourceType 34 | 35 | imagePicker.delegate = context.coordinator 36 | 37 | return imagePicker 38 | } 39 | 40 | func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) { 41 | 42 | } 43 | 44 | final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { 45 | 46 | var parent: ImagePicker 47 | 48 | init(_ parent: ImagePicker) { 49 | self.parent = parent 50 | } 51 | 52 | func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { 53 | 54 | if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage { 55 | //parent.selectedImage = image 56 | parent.presentationMode.wrappedValue.dismiss() 57 | 58 | // cvw: This is where we should call our "completion" closure to do whatever action we're supposed to do with the new image 59 | parent.completion(image) 60 | } 61 | else { 62 | // Just dismiss the view 63 | parent.presentationMode.wrappedValue.dismiss() 64 | // Don't need to call the completion handler 65 | parent.completion(nil) 66 | } 67 | } 68 | } 69 | 70 | } 71 | 72 | 73 | 74 | /* 75 | struct ImagePicker_Previews: PreviewProvider { 76 | static var previews: some View { 77 | ImagePicker() 78 | } 79 | } 80 | */ 81 | -------------------------------------------------------------------------------- /Circles/Views/Composer/PostComposerScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostComposerSheet.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/6/21. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | /* 12 | struct PostComposerScreen: View { 13 | var room: Matrix.Room 14 | var parentMessage: Matrix.Message? 15 | var editingMessage: Matrix.Message? 16 | @State var isPresented = true 17 | @State var title: String 18 | 19 | init(room: Matrix.Room, parentMessage: Matrix.Message? = nil, editingMessage: Matrix.Message? = nil, isPresented: Bool = true) { 20 | self.room = room 21 | self.parentMessage = parentMessage 22 | self.editingMessage = editingMessage 23 | self.isPresented = isPresented 24 | 25 | switch (parentMessage,editingMessage) { 26 | case (.none, .none): 27 | self.title = "New Post" 28 | case (.none, .some): 29 | self.title = "Edit Post" 30 | case (.some, .none): 31 | self.title = "New Reply" 32 | case (.some, .some): 33 | self.title = "Edit Reply" 34 | } 35 | } 36 | 37 | 38 | var body: some View { 39 | VStack { 40 | ScrollView { 41 | if let parent = parentMessage { 42 | MessageCard(message: parent, isThreaded: true) 43 | .padding(3) 44 | .padding(.bottom, 5) 45 | } 46 | PostComposer(room: room, parent: parentMessage, editing: editingMessage) 47 | .padding(3) 48 | .padding(.leading, 10) 49 | } 50 | } 51 | .navigationTitle(self.title) 52 | } 53 | } 54 | */ 55 | 56 | /* 57 | struct MessageComposerSheet_Previews: PreviewProvider { 58 | static var previews: some View { 59 | MessageComposerSheet() 60 | } 61 | } 62 | */ 63 | -------------------------------------------------------------------------------- /Circles/Views/Groups/GenericPersonDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GenericPersonDetailView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 12/14/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct GenericPersonDetailView: View { 12 | @ObservedObject var user: Matrix.User 13 | 14 | var header: some View { 15 | HStack { 16 | UserAvatarView(user: user) 17 | .frame(width: 160, height: 160, alignment: .center) 18 | VStack(alignment: .leading) { 19 | Text(user.displayName ?? "") 20 | .font(.title) 21 | .fontWeight(.bold) 22 | Text(user.id) 23 | .font(.subheadline) 24 | } 25 | } 26 | } 27 | 28 | var body: some View { 29 | VStack { 30 | ScrollView { 31 | header 32 | 33 | //status 34 | 35 | Divider() 36 | 37 | Button(action: {}) { 38 | Label("Invite to connect", systemImage: SystemImages.link.rawValue) 39 | .padding() 40 | } 41 | 42 | Button(action: {}) { 43 | Label("Invite to follow me", systemImage: SystemImages.personLineDottedPersonFill.rawValue) 44 | .padding() 45 | } 46 | 47 | Button(role: .destructive, action: {}) { 48 | Label("Ignore this user", systemImage: SystemImages.personFillXmark.rawValue) 49 | .padding() 50 | } 51 | } 52 | } 53 | .padding() 54 | .onAppear { 55 | // Hit the Homeserver to make sure we have the latest 56 | //user.matrix.getDisplayName(userId: user.id) { _ in } 57 | user.refreshProfile() 58 | } 59 | .navigationTitle(user.displayName ?? user.userId.username) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Circles/Views/Groups/GroupHeader.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020, 2021 Kombucha Digital Privacy Systems LLC 2 | // 3 | // GroupHeader.swift 4 | // Circles for iOS 5 | // 6 | // Created by Charles Wright on 11/18/20. 7 | // 8 | 9 | import SwiftUI 10 | import Matrix 11 | 12 | struct GroupHeader: View { 13 | @ObservedObject var room: Matrix.Room 14 | let content: Content 15 | 16 | init(room: Matrix.Room, @ViewBuilder content: () -> Content) { 17 | self.room = room 18 | self.content = content() 19 | } 20 | 21 | var title: some View { 22 | Text(room.name ?? room.id) 23 | .font(.title) 24 | .fontWeight(.bold) 25 | } 26 | 27 | var subtitle: some View { 28 | Text(room.topic ?? "") 29 | .font(.headline) 30 | .foregroundColor(Color.gray) 31 | } 32 | 33 | var avatar: some View { 34 | RoomAvatarView(room: room, avatarText: .none) 35 | .frame(maxWidth: 150, minHeight: 120, maxHeight: 120) 36 | .shadow(radius: 3) 37 | .padding(5) 38 | } 39 | 40 | var body: some View { 41 | HStack { 42 | 43 | avatar 44 | 45 | VStack(alignment: .center) { 46 | title 47 | .multilineTextAlignment(.center) 48 | 49 | subtitle 50 | .multilineTextAlignment(.center) 51 | .padding(.bottom) 52 | 53 | content 54 | } 55 | 56 | Spacer() 57 | } 58 | .padding(.top, 5) 59 | } 60 | } 61 | 62 | /* 63 | struct GroupHeader_Previews: PreviewProvider { 64 | static var previews: some View { 65 | GroupHeader() 66 | } 67 | } 68 | */ 69 | -------------------------------------------------------------------------------- /Circles/Views/Groups/GroupInvitationsIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GroupInvitationsIndicator.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 8/9/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct GroupInvitationsIndicator: View { 12 | //@Binding var invitations: [Matrix.InvitedRoom] 13 | @ObservedObject var session: Matrix.Session 14 | @ObservedObject var container: ContainerRoom 15 | 16 | var body: some View { 17 | VStack { 18 | let invitations = session.invitations.values.filter { $0.type == ROOM_TYPE_GROUP } 19 | if invitations.count > 0 { 20 | HStack { 21 | Spacer() 22 | NavigationLink(destination: GroupInvitationsView(session: session, container: container)) { 23 | Label("You have \(invitations.count) pending invitation(s)", systemImage: "star") 24 | .fontWeight(.bold) 25 | .padding() 26 | } 27 | Spacer() 28 | Image(systemName: SystemImages.chevronRight.rawValue) 29 | .font(.system(size: 24)) 30 | .padding() 31 | } 32 | .foregroundColor(.white) 33 | .background(Color.accentColor) 34 | .frame(maxHeight: 60) 35 | } 36 | if DebugModel.shared.debugMode { 37 | Text("Debug: \(invitations.count) invitations here; \(session.invitations.count) total in the session") 38 | .foregroundColor(.red) 39 | .padding() 40 | } 41 | } 42 | } 43 | } 44 | 45 | /* 46 | struct GroupInvitationsIndicator_Previews: PreviewProvider { 47 | static var previews: some View { 48 | GroupInvitationsIndicator() 49 | } 50 | } 51 | */ 52 | -------------------------------------------------------------------------------- /Circles/Views/Groups/GroupInvitationsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GroupInvitationsView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 8/9/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct GroupInvitationsView: View { 12 | //@Binding var invitations: [Matrix.InvitedRoom] 13 | @ObservedObject var session: Matrix.Session 14 | var container: ContainerRoom 15 | 16 | var body: some View { 17 | ScrollView { 18 | VStack(alignment: .leading, spacing: 10) { 19 | let invitations = session.invitations.values.filter { $0.type == ROOM_TYPE_GROUP } 20 | if invitations.isEmpty { 21 | Text("No pending invitations") 22 | } else { 23 | ForEach(invitations) { room in 24 | let user = room.session.getUser(userId: room.sender) 25 | InvitedGroupCard(room: room, user: user, container: container) 26 | .frame(maxWidth: 350) 27 | 28 | Divider() 29 | } 30 | } 31 | } 32 | .padding() 33 | } 34 | .navigationTitle(Text("Group Invitations")) 35 | } 36 | } 37 | 38 | /* 39 | struct GroupInvitationsView_Previews: PreviewProvider { 40 | static var previews: some View { 41 | GroupInvitationsView() 42 | } 43 | } 44 | */ 45 | -------------------------------------------------------------------------------- /Circles/Views/Help/CirclesHelpView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CirclesHelpView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 5/28/24. 6 | // 7 | 8 | import SwiftUI 9 | import MarkdownUI 10 | 11 | struct CirclesHelpView: View { 12 | 13 | let helpTextMarkdown = """ 14 | # Circles 15 | 16 | Tip: A **circle** works like a secure, private version of Facebook or Twitter. Everyone posts to their own timeline, and you see posts from all the timelines that you're following. 17 | 18 | A circle is a good way to share things with lots of people who don't all know each other, but who all know you. 19 | 20 | For example, you may have lots of aunts and uncles and cousins from the different sides of your family. 21 | Or, you might have several friends from many different places where you've lived. 22 | 23 | If you want to connect a bunch of people who *do* all know each other, then it's better to create a **Group** instead. 24 | """ 25 | 26 | var body: some View { 27 | ScrollView { 28 | HStack { 29 | BasicImage(name: "iStock-1356527683") 30 | BasicImage(name: "iStock-1304744459") 31 | BasicImage(name: "iStock-1225782571") 32 | BasicImage(name: "iStock-640313068") 33 | } 34 | 35 | Markdown(helpTextMarkdown) 36 | } 37 | .scrollIndicators(.hidden) 38 | .padding() 39 | } 40 | } 41 | 42 | #Preview { 43 | CirclesHelpView() 44 | } 45 | -------------------------------------------------------------------------------- /Circles/Views/Home/SystemNoticesView.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020, 2021 Kombucha Digital Privacy Systems LLC 2 | // 3 | // SystemNoticesView.swift 4 | // Circles for iOS 5 | // 6 | // Created by Charles Wright on 3/3/21. 7 | // 8 | 9 | import SwiftUI 10 | import Matrix 11 | 12 | /* 13 | struct SystemNoticesView: View { 14 | var session: CirclesApplicationSession 15 | @State var scrollEventId: EventId? 16 | 17 | var body: some View { 18 | VStack(alignment: .leading) { 19 | if let room = session.matrix.systemNoticesRoom { 20 | TimelineView(room: room) 21 | .padding(.leading) 22 | } else { 23 | Text("No current notices") 24 | Spacer() 25 | } 26 | } 27 | } 28 | } 29 | 30 | struct SystemNoticesScreen: View { 31 | var session: CirclesApplicationSession 32 | 33 | var body: some View { 34 | VStack { 35 | Label("System Notices", systemImage: "exclamationmark.triangle.fill") 36 | .font(.title2) 37 | .padding() 38 | 39 | SystemNoticesView(session: session) 40 | .navigationBarTitle(Text("System Notices")) 41 | .padding() 42 | } 43 | } 44 | } 45 | */ 46 | 47 | /* 48 | struct SystemNoticesView_Previews: PreviewProvider { 49 | static var previews: some View { 50 | SystemNoticesView() 51 | } 52 | } 53 | */ 54 | -------------------------------------------------------------------------------- /Circles/Views/Login/LegacyLoginScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LegacyLoginScreen.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 10/31/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | import KeychainAccess 11 | 12 | struct LegacyLoginScreen: View { 13 | @ObservedObject var session: LegacyLoginSession 14 | var store: CirclesStore 15 | 16 | @AppStorage("previousUserIds") var previousUserIds: [UserId] = [] 17 | 18 | @State var password: String = "" 19 | @State var showPassword = false 20 | 21 | var body: some View { 22 | VStack { 23 | if DebugModel.shared.debugMode { 24 | Text("m.login.password") 25 | .foregroundColor(.red) 26 | } 27 | Spacer() 28 | 29 | Text("Enter password for \(session.userId.stringValue)") 30 | .font(.title2) 31 | .fontWeight(.bold) 32 | SecureFieldWithEye(password: $password, 33 | isNewPassword: false, 34 | placeholder: "Password") 35 | .textContentType(.password) 36 | .frame(width: 300, height: 40) 37 | .onAppear { 38 | // Attempt to load the saved password that Matrix.swift should have saved in our Keychain 39 | let keychain = Keychain(server: "https://\(session.userId.domain)", protocolType: .https) 40 | keychain.getSharedPassword(session.userId.stringValue) { (passwd, error) in 41 | if self.password.isEmpty, 42 | let savedPassword = passwd 43 | { 44 | self.password = savedPassword 45 | } 46 | } 47 | } 48 | 49 | AsyncButton(action: { 50 | try await session.login(password: password) 51 | 52 | // Add our user id to the list, for easy login in the future 53 | let allUserIds: Set = Set(previousUserIds).union([session.userId]) 54 | previousUserIds = allUserIds.sorted { $0.stringValue < $1.stringValue } 55 | 56 | // Save our password in the Keychain 57 | let keychain = Keychain(server: "https://\(session.userId.domain)", protocolType: .https) 58 | keychain.setSharedPassword(password, account: session.userId.stringValue) 59 | }) { 60 | Text("Log In") 61 | } 62 | .buttonStyle(BigRoundedButtonStyle()) 63 | 64 | Spacer() 65 | 66 | AsyncButton(role: .destructive, action: { 67 | try await store.disconnect() 68 | }) { 69 | Text("Cancel") 70 | } 71 | } 72 | .padding() 73 | } 74 | } 75 | 76 | -------------------------------------------------------------------------------- /Circles/Views/Login/SecretStorageCreationScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SecretStorageCreationScreen.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 1/29/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | import IDZSwiftCommonCrypto 11 | 12 | struct SecretStorageCreationScreen: View { 13 | var store: CirclesStore 14 | @ObservedObject var matrix: Matrix.Session 15 | 16 | var body: some View { 17 | VStack { 18 | 19 | Spacer() 20 | 21 | Text("This account does not appear to be set up for secure storage on the server.") 22 | .font(.title2) 23 | .padding() 24 | 25 | Spacer() 26 | 27 | Label("Tip: If this is a new account, tap \"Set up secure storage\" to get started.", systemImage: "lightbulb.fill") 28 | 29 | AsyncButton(action: { 30 | guard let _ = matrix.secretStore // let secretStore 31 | else { 32 | print("No secret storage!") 33 | return 34 | } 35 | let bytes = try Random.generateBytes(byteCount: 32) 36 | let data = Data(bytes) 37 | let keyId = try Random.generateBytes(byteCount: 16) 38 | .map { 39 | String(format: "%02hhx", $0) 40 | } 41 | .joined() 42 | let description = try Matrix.SecretStore.generateKeyDescription(key: data, keyId: keyId) 43 | let key = Matrix.SecretStorageKey(key: data, keyId: keyId, description: description) 44 | print("Initializing secret storage with key id \(keyId)") 45 | try await store.initSecretStorage(key: key) 46 | print("Done initializing secret storage") 47 | }) { 48 | Text("Set up secure storage") 49 | .padding() 50 | } 51 | .padding() 52 | 53 | Spacer() 54 | 55 | AsyncButton(role: .destructive, action: { 56 | try await store.disconnect() 57 | }) { 58 | Text("Cancel") 59 | } 60 | } 61 | .padding() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Circles/Views/People/FollowersView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FollowersView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/18/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct FollowersView: View { 12 | @ObservedObject var profile: ProfileSpace 13 | @Binding var followers: [Matrix.User] 14 | 15 | var body: some View { 16 | ScrollView { 17 | LazyVStack(alignment: .leading, spacing: 10) { 18 | ForEach(followers) { user in 19 | NavigationLink(destination: PersonDetailView(user: user, myProfileRoom: profile)) { 20 | PersonHeaderRow(user: user, profile: profile) 21 | .contentShape(Rectangle()) 22 | } 23 | .buttonStyle(.plain) 24 | Divider() 25 | } 26 | } 27 | } 28 | .padding() 29 | .navigationTitle("My Followers") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Circles/Views/People/FollowingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FollowingView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/18/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct FollowingView: View { 12 | @ObservedObject var profile: ProfileSpace 13 | @Binding var following: [Matrix.User] 14 | 15 | var body: some View { 16 | ScrollView { 17 | LazyVStack(alignment: .leading, spacing: 10) { 18 | ForEach(following) { user in 19 | NavigationLink(destination: PersonDetailView(user: user, myProfileRoom: profile)) { 20 | PersonHeaderRow(user: user, profile: profile) 21 | .contentShape(Circle()) 22 | } 23 | .buttonStyle(.plain) 24 | Divider() 25 | } 26 | } 27 | } 28 | .padding() 29 | .navigationTitle("Following") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Circles/Views/People/FriendsOfFriendsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FriendsOfFriendsView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/18/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct FriendsOfFriendsView: View { 12 | @ObservedObject var profile: ProfileSpace 13 | @ObservedObject var people: ContainerRoom 14 | @Binding var friendsOfFriends: [UserId]? 15 | 16 | var body: some View { 17 | ScrollView { 18 | if let userIds = friendsOfFriends { 19 | LazyVStack(alignment: .leading, spacing: 10) { 20 | ForEach(userIds, id: \.self) { userId in 21 | if userId != profile.session.creds.userId { 22 | let user = profile.session.getUser(userId: userId) 23 | 24 | NavigationLink(destination: PersonDetailView(user: user, myProfileRoom: profile)) { 25 | PersonHeaderRow(user: user, profile: profile) 26 | //.contentShape(Rectangle()) 27 | } 28 | .buttonStyle(.plain) 29 | 30 | Divider() 31 | } 32 | } 33 | } 34 | } else { 35 | ProgressView("Loading...") 36 | } 37 | } 38 | .padding() 39 | .navigationTitle("Friends of Friends") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Circles/Views/People/InviteToFollowMeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InviteToFollowMeView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 5/2/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct InviteToFollowMeView: View { 12 | var user: Matrix.User 13 | @Environment(\.presentationMode) var presentation 14 | 15 | @State var selected: Set = [] 16 | 17 | var body: some View { 18 | ScrollView { 19 | VStack { 20 | 21 | Text("Which of your timelines do you want \(user.displayName ?? user.userId.username) to follow?") 22 | .font(.title2) 23 | .padding() 24 | 25 | Text("Select one or more") 26 | 27 | CirclePicker(selected: $selected) 28 | .padding() 29 | 30 | AsyncButton(action: { 31 | for room in selected { 32 | if room.invitedMembers.contains(user.userId) { 33 | print("User \(user.userId) is already following us in circle \(room.name ?? room.roomId.stringValue)") 34 | } else { 35 | try await room.invite(userId: user.userId) 36 | } 37 | } 38 | }) { 39 | Text("Send \(selected.count) invitation(s)") 40 | } 41 | .disabled(selected.isEmpty) 42 | .padding() 43 | 44 | Button(role: .destructive, action: { 45 | self.presentation.wrappedValue.dismiss() 46 | }) { 47 | Text("Cancel") 48 | } 49 | .padding() 50 | } 51 | } 52 | .navigationTitle("Invite to Follow Me") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Circles/Views/People/MutualFriendsSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MutualFriendsSection.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/17/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct MutualFriendsSection: View { 12 | @ObservedObject var user: Matrix.User 13 | @ObservedObject var profile: ProfileSpace 14 | 15 | @State var mutualFriends: [Matrix.User]? = nil 16 | 17 | 18 | var body: some View { 19 | LazyVStack(alignment: .leading) { 20 | Text("MUTUAL FRIENDS") 21 | .font(.subheadline) 22 | .foregroundColor(.gray) 23 | .padding(.top) 24 | if let friends = mutualFriends { 25 | if friends.isEmpty { 26 | Text("No mutual friends") 27 | .padding() 28 | } else { 29 | ForEach(friends) { friend in 30 | // FIXME: Should probably check to see if we're connected to this person's profile space 31 | // And if we are, use the ConnectedPersonDetailView 32 | NavigationLink(destination: PersonDetailView(user: friend, myProfileRoom: profile)) { 33 | PersonHeaderRow(user: friend, profile: profile) 34 | } 35 | .buttonStyle(.plain) 36 | } 37 | } 38 | } else { 39 | ProgressView() 40 | } 41 | } 42 | .onAppear { 43 | Task { 44 | let session = user.session 45 | // First find the set of circles that we're both in 46 | let rooms: [Matrix.Room] = session.rooms.values.filter { room in 47 | room.type == ROOM_TYPE_CIRCLE && room.joinedMembers.contains(user.userId) 48 | } 49 | // Find the users in those circles who are not (1) me or (2) them 50 | let users: Set = rooms.reduce([], { curr, room in 51 | let roomMembers: [Matrix.User] = room.joinedMembers.filter { 52 | $0 != user.userId && $0 != user.session.creds.userId 53 | }.compactMap { 54 | session.getUser(userId: $0) 55 | } 56 | return curr.union(roomMembers) 57 | }) 58 | // Sort the set in order to get an Array 59 | let sortedUsers = users.sorted(by: {u0,u1 in 60 | u0.userId.stringValue < u1.userId.stringValue 61 | }) 62 | // Update the UI state on the main thread 63 | await MainActor.run { 64 | self.mutualFriends = sortedUsers 65 | } 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Circles/Views/People/PeopleInvitationsIndicator.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2023 FUTO Holdings Inc 2 | // 3 | // PeopleInvitationsIndicator.swift 4 | // Circles 5 | // 6 | // Created by Michael Hollister on 9/5/23. 7 | // 8 | 9 | import SwiftUI 10 | import Matrix 11 | 12 | struct PeopleInvitationsIndicator: View { 13 | //@Binding var invitations: [Matrix.InvitedRoom] 14 | @ObservedObject var session: Matrix.Session 15 | var container: ContainerRoom 16 | 17 | @State var invitations: [Matrix.InvitedRoom] = [] 18 | 19 | var body: some View { 20 | HStack { 21 | Spacer() 22 | 23 | if !invitations.isEmpty { 24 | NavigationLink(destination: PeopleInvitationsView(session: session, people: container)) { 25 | Label("\(invitations.count) invitation(s) to connect", systemImage: "envelope.open.fill") 26 | } 27 | } 28 | 29 | Spacer() 30 | } 31 | .onAppear { 32 | invitations = session.invitations.values.filter { $0.type == M_SPACE } 33 | } 34 | } 35 | } 36 | 37 | /* 38 | struct PeopleInvitationsIndicator_Previews: PreviewProvider { 39 | static var previews: some View { 40 | PeopleInvitationsIndicator() 41 | } 42 | } 43 | */ 44 | -------------------------------------------------------------------------------- /Circles/Views/People/PersonDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnconnectedPersonDetailView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/31/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct PersonDetailView: View { 12 | @ObservedObject var user: Matrix.User 13 | @ObservedObject var myProfileRoom: ProfileSpace 14 | 15 | @State private var alertTitle: String = "" 16 | @State private var alertMessage: String = "" 17 | @State private var showAlert = false 18 | 19 | var status: some View { 20 | HStack { 21 | Text("Latest Status:") 22 | .fontWeight(.bold) 23 | Text(user.statusMessage ?? "(no status message)") 24 | } 25 | .font(.subheadline) 26 | } 27 | 28 | var header: some View { 29 | HStack { 30 | Spacer() 31 | 32 | VStack { 33 | UserAvatarView(user: user) 34 | .frame(width: 160, height: 160, alignment: .center) 35 | // .padding(.leading) 36 | Text(user.displayName ?? "") 37 | .font(.title) 38 | .fontWeight(.bold) 39 | Text(user.userId.stringValue) 40 | .font(.subheadline) 41 | .foregroundColor(.gray) 42 | NavigationLink(destination: InviteToFollowMeView(user: user)) { 43 | Label("Invite to follow me", systemImage: "circle.hexagonpath") 44 | } 45 | } 46 | 47 | Spacer() 48 | } 49 | } 50 | 51 | var body: some View { 52 | ScrollView { 53 | VStack(alignment: .leading) { 54 | header 55 | 56 | //status 57 | 58 | Divider() 59 | 60 | MutualFriendsSection(user: user, profile: myProfileRoom) 61 | } 62 | } 63 | .padding() 64 | .onAppear { 65 | // Hit the Homeserver to make sure we have the latest 66 | //user.matrix.getDisplayName(userId: user.id) { _ in } 67 | user.refreshProfile() 68 | } 69 | .navigationTitle(Text(user.displayName ?? user.userId.username)) 70 | } 71 | } 72 | 73 | /* 74 | struct UnconnectedPersonDetailView_Previews: PreviewProvider { 75 | static var previews: some View { 76 | UnconnectedPersonDetailView() 77 | } 78 | } 79 | */ 80 | -------------------------------------------------------------------------------- /Circles/Views/People/PersonsCircleRow.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020, 2021 Kombucha Digital Privacy Systems LLC 2 | // 3 | // PersonsChannelRow.swift 4 | // Circles for iOS 5 | // 6 | // Created by Charles Wright on 12/3/20. 7 | // 8 | 9 | import SwiftUI 10 | import Matrix 11 | 12 | struct PersonsCircleRow: View { 13 | @ObservedObject var room: Matrix.SpaceChildRoom 14 | @ObservedObject var user: Matrix.User 15 | 16 | init(room: Matrix.SpaceChildRoom) { 17 | self.room = room 18 | self.user = room.session.getUser(userId: room.creator) 19 | } 20 | 21 | var roomName: String { 22 | room.name ?? "" 23 | } 24 | 25 | var userName: String { 26 | return user.displayName ?? "\(user.userId)" 27 | } 28 | 29 | var body: some View { 30 | HStack(alignment: .center) { 31 | let frameSize: CGFloat = 40 32 | 33 | RoomAvatarView(room: room, avatarText: .none) 34 | .frame(width: frameSize, height: frameSize) 35 | .clipped() 36 | 37 | VStack(alignment: .leading) { 38 | 39 | Text(roomName) 40 | .font(.headline) 41 | .fontWeight(.semibold) 42 | 43 | /* 44 | Text(room.id) 45 | .font(.caption) 46 | .foregroundColor(Color.gray) 47 | */ 48 | 49 | } 50 | 51 | //Image(systemName: SystemImages.chevronRight.rawValue) 52 | 53 | Spacer() 54 | 55 | } 56 | .padding(.leading, 5) 57 | } 58 | } 59 | 60 | /* 61 | struct PersonsChannelRow_Previews: PreviewProvider { 62 | static var previews: some View { 63 | PersonsChannelRow() 64 | } 65 | } 66 | */ 67 | -------------------------------------------------------------------------------- /Circles/Views/People/SelfDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelfDetailView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/31/23. 6 | // 7 | 8 | import SwiftUI 9 | import UIKit 10 | import CoreImage 11 | import CoreImage.CIFilterBuiltins 12 | import Matrix 13 | 14 | struct SelfDetailView: View { 15 | @ObservedObject var profile: ContainerRoom 16 | 17 | @State var showPicker = false 18 | @State var showConfirmRemove = false 19 | 20 | var body: some View { 21 | ScrollView { 22 | VStack(alignment: .leading) { 23 | HStack { 24 | Spacer() 25 | VStack(alignment: .center) { 26 | let me = profile.session.me 27 | UserAvatarView(user: me) 28 | .aspectRatio(contentMode: .fill) 29 | .frame(width: 240, height: 240) 30 | 31 | Text(me.displayName ?? me.userId.username) 32 | .font(.title) 33 | .fontWeight(.bold) 34 | 35 | Text(me.userId.stringValue) 36 | .font(.subheadline) 37 | .foregroundColor(.gray) 38 | } 39 | Spacer() 40 | } 41 | } 42 | .padding() 43 | } 44 | .navigationTitle(Text("Me")) 45 | } 46 | } 47 | 48 | /* 49 | struct SelfDetailView_Previews: PreviewProvider { 50 | static var previews: some View { 51 | SelfDetailView() 52 | } 53 | } 54 | */ 55 | -------------------------------------------------------------------------------- /Circles/Views/Photo Galleries/GalleryInvitationsIndicator.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2023 FUTO Holdings Inc 2 | // 3 | // GalleryInvitationsIndicator.swift 4 | // Circles 5 | // 6 | // Created by Michael Hollister on 9/5/23. 7 | // 8 | 9 | import SwiftUI 10 | import Matrix 11 | 12 | struct GalleryInvitationsIndicator: View { 13 | //@Binding var invitations: [Matrix.InvitedRoom] 14 | @ObservedObject var session: Matrix.Session 15 | var container: ContainerRoom 16 | 17 | 18 | var body: some View { 19 | VStack(alignment: .leading) { 20 | let invitations = session.invitations.values.filter { $0.type == ROOM_TYPE_PHOTOS } 21 | if !invitations.isEmpty { 22 | 23 | NavigationLink(destination: GalleryInvitationsView(session: session, container: container)) { 24 | HStack { 25 | Spacer() 26 | Label("You have \(invitations.count) pending invitation(s)", systemImage: "star") 27 | .fontWeight(.bold) 28 | .padding() 29 | Spacer() 30 | Image(systemName: SystemImages.chevronRight.rawValue) 31 | .font(.system(size: 24)) 32 | .padding() 33 | } 34 | .foregroundColor(.white) 35 | .background(Color.accentColor) 36 | .frame(maxHeight: 80) 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | /* 44 | struct GalleryInvitationsIndicator_Previews: PreviewProvider { 45 | static var previews: some View { 46 | GalleryInvitationsIndicator() 47 | } 48 | } 49 | */ 50 | -------------------------------------------------------------------------------- /Circles/Views/Photo Galleries/GalleryInvitationsView.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2023 FUTO Holdings Inc 2 | // 3 | // GalleryInvitationsView.swift 4 | // Circles 5 | // 6 | // Created by Charles Wright on 8/1/23. 7 | // 8 | 9 | import SwiftUI 10 | import Matrix 11 | 12 | struct GalleryInvitationsView: View { 13 | @ObservedObject var session: Matrix.Session 14 | @ObservedObject var container: ContainerRoom 15 | 16 | var body: some View { 17 | ScrollView { 18 | VStack(spacing: 10) { 19 | let invitations = session.invitations.values.filter { $0.type == ROOM_TYPE_PHOTOS } 20 | if invitations.isEmpty { 21 | Text("No current invitations") 22 | } else { 23 | ForEach(invitations) { invitation in 24 | let user = session.getUser(userId: invitation.sender) 25 | GalleryInviteCard(room: invitation, user: user, container: container) 26 | Divider() 27 | } 28 | } 29 | } 30 | } 31 | .navigationTitle(Text("Invitations")) 32 | } 33 | } 34 | 35 | /* 36 | struct GalleryInvitationsView_Previews: PreviewProvider { 37 | static var previews: some View { 38 | GalleryInvitationsView() 39 | } 40 | } 41 | */ 42 | -------------------------------------------------------------------------------- /Circles/Views/Photo Galleries/PhotoContextMenu.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2023 FUTO Holdings Inc 2 | // 3 | // PhotoContextMenu.swift 4 | // Circles 5 | // 6 | // Created by Charles Wright on 4/18/23. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | import Matrix 13 | 14 | struct PhotoContextMenu: View { 15 | var message: Matrix.Message 16 | @Binding var showDetail: Bool 17 | 18 | var body: some View { 19 | let current = message.replacement ?? message 20 | 21 | if let content = current.content as? Matrix.MessageContent, 22 | content.msgtype == M_IMAGE, 23 | let imageContent = content as? Matrix.mImageContent 24 | { 25 | AsyncButton(action: { 26 | try await saveImage(content: imageContent, session: message.room.session) 27 | }) { 28 | Label("Save image", systemImage: "square.and.arrow.down") 29 | } 30 | 31 | if let thumbnail = current.thumbnail 32 | { 33 | let image = Image(uiImage: thumbnail) 34 | ShareLink(item: image, preview: SharePreview(imageContent.caption ?? "", image: image)) 35 | } 36 | } 37 | 38 | Button(action: { 39 | message.objectWillChange.send() 40 | }) { 41 | Label("Refresh", systemImage: "arrow.clockwise") 42 | } 43 | 44 | Button(action: { 45 | self.showDetail = true 46 | }) { 47 | Label("Show detailed view", systemImage: "magnifyingglass") 48 | } 49 | 50 | if message.iCanRedact { 51 | AsyncButton(action: { 52 | try await deleteAndPurge(message: message) 53 | }) { 54 | Label("Delete", systemImage: SystemImages.trash.rawValue) 55 | } 56 | .foregroundColor(.red) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Circles/Views/Photo Galleries/VideoDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoDetailView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/17/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct VideoDetailView: View { 12 | @ObservedObject var message: Matrix.Message 13 | 14 | var body: some View { 15 | VideoContentView(message: message, autoplay: true, fullscreen: true) 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /Circles/Views/Photo Galleries/VideoThumbnailCard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoThumbnailCard.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/17/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct VideoThumbnailCard: View { 12 | @ObservedObject var message: Matrix.Message 13 | var height: CGFloat 14 | var width: CGFloat 15 | @State var playVideo: Bool = false 16 | 17 | @Environment(\.colorScheme) var colorScheme 18 | 19 | var body: some View { 20 | ZStack { 21 | if message.type == M_ROOM_MESSAGE { 22 | if let img = message.thumbnail { 23 | Image(uiImage: img) 24 | .renderingMode(.original) 25 | .resizable() 26 | .aspectRatio(contentMode: .fill) 27 | .frame(width: width, height: height, alignment: .center) 28 | .clipped() 29 | 30 | BasicImage(systemName: SystemImages.playCircle.rawValue) 31 | .foregroundColor(.white) 32 | .shadow(color: .black, radius: /*@START_MENU_TOKEN@*/10/*@END_MENU_TOKEN@*/) 33 | .frame(width: width/2, height: height/2) 34 | .onTapGesture { 35 | self.playVideo = true 36 | } 37 | .fullScreenCover(isPresented: $playVideo) { 38 | VideoDetailView(message: message) 39 | } 40 | 41 | } else { 42 | Color.gray 43 | .onAppear { 44 | let _ = Task { 45 | try await message.fetchThumbnail() 46 | } 47 | } 48 | ProgressView() 49 | } 50 | } 51 | else { 52 | VStack { 53 | let bgColor = colorScheme == .dark ? Color.black : Color.white 54 | BasicImage(systemName: SystemImages.lockRectangle.rawValue) 55 | .foregroundColor(Color.gray) 56 | .padding() 57 | VStack { 58 | Text("Decryption error") 59 | if DebugModel.shared.debugMode { 60 | Text("Message id: \(message.id)") 61 | .font(.footnote) 62 | } 63 | } 64 | .multilineTextAlignment(.center) 65 | .foregroundColor(.gray) 66 | .background( 67 | bgColor 68 | .opacity(0.5) 69 | ) 70 | .padding(.bottom, 2) 71 | } 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Circles/Views/Rooms/BannedRoomMemberRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BannedRoomMemberRow.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/15/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct BannedRoomMemberRow: View { 12 | @ObservedObject var user: Matrix.User 13 | @ObservedObject var room: Matrix.Room 14 | 15 | @State var showConfirmUnban = false 16 | 17 | var body: some View { 18 | HStack { 19 | UserAvatarView(user: user) 20 | .frame(width: 60, height: 60) 21 | VStack(alignment: .leading) { 22 | UserNameView(user: user) 23 | Text(user.userId.stringValue) 24 | .font(.subheadline) 25 | .foregroundColor(.gray) 26 | } 27 | Spacer() 28 | Button(action: { 29 | self.showConfirmUnban = true 30 | }) { 31 | Label("Un-ban", systemImage: "trash.slash") 32 | } 33 | .disabled(!room.iCanUnban(userId: user.userId)) 34 | .confirmationDialog( 35 | "Confirm un-banning", 36 | isPresented: $showConfirmUnban, 37 | actions: { 38 | AsyncButton(action: {}) { 39 | Text("Un-ban \(user.displayName ?? user.userId.stringValue)") 40 | } 41 | }, 42 | message: { 43 | Label("This will allow the user to re-join in the future", systemImage: "exclamationmark.triangle.fill") 44 | .foregroundColor(.red) 45 | } 46 | ) 47 | } 48 | } 49 | } 50 | 51 | 52 | -------------------------------------------------------------------------------- /Circles/Views/Rooms/KnockOnRoomView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KnockOnRoomView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/9/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct KnockOnRoomView: View { 12 | var roomId: RoomId 13 | var session: Matrix.Session 14 | 15 | @Environment(\.presentationMode) var presentation 16 | 17 | @State var reason = "" 18 | 19 | var body: some View { 20 | VStack { 21 | Label("Request invitation", systemImage: "checkmark.circle.fill") 22 | .symbolRenderingMode(.multicolor) 23 | .font(.title2) 24 | .padding() 25 | 26 | VStack(alignment: .leading) { 27 | Text("Include an optional message:") 28 | .foregroundColor(.gray) 29 | 30 | TextEditor(text: $reason) 31 | .lineLimit(5) 32 | .border(Color.gray) 33 | 34 | Label("Warning: Your message will not be encrypted, and is accessible by all current members", systemImage: SystemImages.exclamationmarkShield.rawValue) 35 | .foregroundColor(.orange) 36 | } 37 | 38 | AsyncButton(action: { 39 | print("Sending knock to \(roomId.stringValue)") 40 | if reason.isEmpty { 41 | try await session.knock(roomId: roomId, reason: nil) 42 | } else { 43 | try await session.knock(roomId: roomId, reason: reason) 44 | 45 | } 46 | self.presentation.wrappedValue.dismiss() 47 | }) { 48 | Label("Send request for invite", systemImage: "paperplane.fill") 49 | } 50 | .padding() 51 | 52 | Spacer() 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Circles/Views/Rooms/RoomCryptoInfoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoomCryptoInfoView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 1/27/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | import MatrixSDKCrypto 11 | 12 | struct RoomCryptoInfoView: View { 13 | @ObservedObject var room: Matrix.Room 14 | 15 | var body: some View { 16 | Form { 17 | ForEach(room.joinedMembers) { userId in 18 | let user = room.session.getUser(userId: userId) 19 | if !user.devices.isEmpty { 20 | Section(user.displayName ?? user.userId.stringValue) { 21 | ForEach(user.devices) { device in 22 | NavigationLink(destination: DeviceDetailsView(session: room.session, device: device)) { 23 | DeviceInfoView(session: room.session, device: device) 24 | } 25 | .buttonStyle(.plain) 26 | } 27 | } 28 | } 29 | } 30 | } 31 | .navigationTitle("Crypto Info") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Circles/Views/Rooms/RoomDebugDetailsSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoomDebugDetailsSection.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 2/6/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct RoomDebugDetailsSection: View { 12 | @ObservedObject var room: Matrix.Room 13 | 14 | var body: some View { 15 | if DebugModel.shared.debugMode { 16 | Section("Matrix Debug Details") { 17 | Text("History visibiilty") 18 | .badge(room.historyVisibility?.rawValue ?? "unknown") 19 | Text("Join rule") 20 | .badge(room.joinRule?.rawValue ?? "unknown") 21 | Text("Encryption algorithm") 22 | .badge(room.encryptionParams?.algorithm.rawValue ?? "none") 23 | if let ms = room.encryptionParams?.rotationPeriodMs { 24 | let sec = ms / 1000 25 | Text("Rotation (sec)") 26 | .badge("\(sec)") 27 | } 28 | if let msgs = room.encryptionParams?.rotationPeriodMsgs { 29 | Text("Rotation (msgs)") 30 | .badge("\(msgs)") 31 | } 32 | } 33 | } 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /Circles/Views/Rooms/RoomDefaultPowerLevelPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoomDefaultPowerLevelPicker.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/9/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct RoomDefaultPowerLevelPicker: View { 12 | @ObservedObject var room: Matrix.Room 13 | 14 | @State var selected: PowerLevel 15 | 16 | init(room: Matrix.Room) { 17 | self.room = room 18 | let level = PowerLevel(power: room.powerLevels?.usersDefault ?? 0) 19 | self._selected = State(wrappedValue: level) 20 | } 21 | 22 | var body: some View { 23 | Picker("Default", selection: $selected) { 24 | ForEach(CIRCLES_POWER_LEVELS) { level in 25 | Text(level.description) 26 | .tag(level) 27 | } 28 | } 29 | .onChange(of: selected) { newLevel in 30 | Task { 31 | print("Setting new power level") 32 | try await room.setPowerLevel(usersDefault: newLevel.power) 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Circles/Views/Rooms/RoomInviteOneUserSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoomInviteOneUserSheet.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 12/15/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct RoomInviteOneUserSheet: View { 12 | @ObservedObject var room: Matrix.Room 13 | @ObservedObject var user: Matrix.User 14 | 15 | @Environment(\.presentationMode) var presentation 16 | 17 | @State private var message = "" 18 | 19 | var subtitle: String { 20 | switch room.type { 21 | case ROOM_TYPE_CIRCLE: 22 | return "To follow my \(room.name ?? "") timeline" 23 | case ROOM_TYPE_GROUP: 24 | return "To join \(room.name ?? "a group")" 25 | case ROOM_TYPE_SPACE: 26 | return "To connect with \(room.name ?? "me")" 27 | case ROOM_TYPE_PHOTOS: 28 | return "To see photos in \(room.name ?? "a gallery")" 29 | default: 30 | return "To join \(room.name ?? "")" 31 | } 32 | } 33 | 34 | var body: some View { 35 | VStack { 36 | VStack { 37 | Text("Inviting \(user.displayName ?? user.userId.stringValue)") 38 | .font(.title2) 39 | Text(subtitle) 40 | } 41 | .padding() 42 | 43 | VStack(alignment: .leading) { 44 | Text("Message:") 45 | TextEditor(text: $message) 46 | .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.gray)) 47 | } 48 | .padding() 49 | 50 | HStack { 51 | Spacer() 52 | 53 | Button(role: .destructive, action: { 54 | self.presentation.wrappedValue.dismiss() 55 | }) { 56 | Text("Cancel") 57 | .padding(5) 58 | } 59 | .buttonStyle(.bordered) 60 | 61 | Spacer() 62 | 63 | AsyncButton(action: { 64 | try await room.invite(userId: user.userId, reason: message.isEmpty ? nil : message) 65 | self.presentation.wrappedValue.dismiss() 66 | }) { 67 | Text("Send invitation") 68 | .padding(5) 69 | } 70 | .buttonStyle(.bordered) 71 | 72 | Spacer() 73 | } 74 | } 75 | } 76 | } 77 | 78 | -------------------------------------------------------------------------------- /Circles/Views/Rooms/RoomInvitedMemberRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircleInvitedFollowerRow.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 11/7/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | // This renders a row in a list, showing a user who we have invited to join one of our rooms 12 | struct RoomInvitedMemberRow: View { 13 | var room: Matrix.Room 14 | @ObservedObject var user: Matrix.User 15 | 16 | @State var showConfirmCancel = false 17 | 18 | var body: some View { 19 | HStack { 20 | UserAvatarView(user: user) 21 | .frame(width: 60, height: 60) 22 | VStack(alignment: .leading) { 23 | UserNameView(user: user) 24 | Text(user.userId.stringValue) 25 | .font(.subheadline) 26 | .foregroundColor(.gray) 27 | } 28 | 29 | Spacer() 30 | 31 | Button(role: .destructive, action: { 32 | // Cancel invite 33 | self.showConfirmCancel = true 34 | }) { 35 | Image(systemName: SystemImages.trash.rawValue) 36 | } 37 | .disabled(!room.iCanKick) 38 | .confirmationDialog( 39 | "Cancel invitation?", 40 | isPresented: $showConfirmCancel, 41 | actions: { 42 | AsyncButton(role: .destructive, action: { 43 | try await room.kick(userId: user.userId, reason: "Canceling invitation") 44 | }) { 45 | Text("Cancel invitation") 46 | } 47 | } 48 | ) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Circles/Views/Rooms/RoomKnockDetailsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoomKnockDetailsView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 10/10/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct RoomKnockDetailsView: View { 12 | @ObservedObject var room: Matrix.Room 13 | 14 | 15 | 16 | var body: some View { 17 | ScrollView { 18 | VStack { 19 | ForEach(room.knockingMembers) { userId in 20 | //ForEach([UserId("@cvwright:circu.li")!]) { userId in 21 | Divider() 22 | let user = room.session.getUser(userId: userId) 23 | KnockingUserCard(user: user, room: room) 24 | } 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Circles/Views/Rooms/RoomKnockIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoomKnockIndicator.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 10/10/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct RoomKnockIndicator: View { 12 | @ObservedObject var room: Matrix.Room 13 | 14 | var body: some View { 15 | if room.iCanInvite { 16 | HStack { 17 | Spacer() 18 | NavigationLink(destination: RoomKnockDetailsView(room: room)) { 19 | Label("Review \(room.knockingMembers.count) request(s) for invitations", systemImage: "star.fill") 20 | .fontWeight(.bold) 21 | .foregroundColor(.white) 22 | .padding() 23 | } 24 | Spacer() 25 | } 26 | .background(Color.accentColor) 27 | .frame(maxHeight: 60) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Circles/Views/Rooms/RoomMembersSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoomMembersSection.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 11/7/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct RoomMembersSection: View { 12 | var title: String 13 | var users: [UserId] 14 | var room: Matrix.Room 15 | 16 | var body: some View { 17 | Section(title) { 18 | ForEach(users) { userId in 19 | let user = room.session.getUser(userId: userId) 20 | NavigationLink(destination: RoomMemberDetailView(user: user, room: room)) { 21 | RoomMemberRow(user: user, room: room) 22 | } 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Circles/Views/Rooms/RoomRenameView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoomRenameView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/15/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct RoomRenameView: View { 12 | @ObservedObject var room: Matrix.Room 13 | @Environment(\.presentationMode) var presentation 14 | 15 | @State var newName: String 16 | 17 | enum FocusField { 18 | case roomName 19 | } 20 | @FocusState var focus: FocusField? 21 | 22 | init(room: Matrix.Room) { 23 | self.room = room 24 | self._newName = State(wrappedValue: room.name ?? "") 25 | } 26 | 27 | var body: some View { 28 | VStack(alignment: .center, spacing: 100) { 29 | Spacer() 30 | 31 | HStack { 32 | TextField(room.name ?? "", text: $newName, prompt: Text("New name")) 33 | .textFieldStyle(.roundedBorder) 34 | .textInputAutocapitalization(.words) 35 | .focused($focus, equals: .roomName) 36 | .frame(width: 300, height: 40) 37 | .onAppear { 38 | self.focus = .roomName 39 | } 40 | Button(action: { 41 | self.newName = "" 42 | }) { 43 | Image(systemName: SystemImages.xmark.rawValue) 44 | .foregroundColor(.gray) 45 | } 46 | } 47 | 48 | AsyncButton(action: { 49 | try await room.setName(newName: newName) 50 | self.presentation.wrappedValue.dismiss() 51 | }) { 52 | Text("Update") 53 | } 54 | 55 | Spacer() 56 | } 57 | .navigationTitle("Rename \(room.name ?? "")") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Circles/Views/Rooms/RoomTopicEditorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoomTopicEditorView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/15/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct RoomTopicEditorView: View { 12 | @ObservedObject var room: Matrix.Room 13 | @Environment(\.presentationMode) var presentation 14 | 15 | @State var newTopic: String 16 | 17 | enum FocusField { 18 | case topic 19 | } 20 | @FocusState var focus: FocusField? 21 | 22 | init(room: Matrix.Room) { 23 | self.room = room 24 | self._newTopic = State(wrappedValue: room.topic ?? "") 25 | } 26 | 27 | var body: some View { 28 | VStack(alignment: .center, spacing: 100) { 29 | Spacer() 30 | 31 | HStack { 32 | TextField(room.name ?? "", text: $newTopic, prompt: Text("New topic")) 33 | .textFieldStyle(.roundedBorder) 34 | .textInputAutocapitalization(.sentences) 35 | .focused($focus, equals: .topic) 36 | .frame(width: 300, height: 40) 37 | .onAppear { 38 | self.focus = .topic 39 | } 40 | Button(action: { 41 | self.newTopic = "" 42 | }) { 43 | Image(systemName: SystemImages.xmark.rawValue) 44 | .foregroundColor(.gray) 45 | } 46 | } 47 | 48 | AsyncButton(action: { 49 | try await room.setTopic(newTopic: newTopic) 50 | self.presentation.wrappedValue.dismiss() 51 | }) { 52 | Text("Update") 53 | } 54 | 55 | Spacer() 56 | } 57 | .navigationTitle("Change Topic") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Circles/Views/Rooms/ScanQrCodeAndKnockSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScanQrCodeAndKnockView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 10/11/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct ScanQrCodeAndKnockSheet: View { 12 | var session: Matrix.Session 13 | @State var reason: String = "" 14 | @State var roomId: RoomId? = nil 15 | @Environment(\.presentationMode) var presentation 16 | @EnvironmentObject var app: CirclesApplicationSession 17 | 18 | var body: some View { 19 | VStack { 20 | if let roomId = roomId { 21 | if let room = session.invitations[roomId] { 22 | 23 | Spacer() 24 | 25 | Text("You already have an invitation!") 26 | .font(.title2) 27 | 28 | Spacer() 29 | 30 | let user = session.getUser(userId: room.sender) 31 | switch room.type { 32 | case ROOM_TYPE_CIRCLE: 33 | InvitedCircleCard(room: room, user: user, container: app.timelines) 34 | .frame(maxWidth: 400) 35 | case ROOM_TYPE_GROUP: 36 | InvitedGroupCard(room: room, user: user, container: app.groups) 37 | .frame(maxWidth: 400) 38 | case ROOM_TYPE_PHOTOS: 39 | GalleryInviteCard(room: room, user: user, container: app.galleries) 40 | .frame(maxWidth: 400) 41 | default: 42 | Label("This invitation does not look like it is for use with Circles", systemImage: "exclamationmark.triangle.fill") 43 | } 44 | 45 | Spacer() 46 | 47 | } else { 48 | KnockOnRoomView(roomId: roomId, session: session) 49 | } 50 | } else { 51 | ScanQrCodeView(roomId: $roomId) 52 | } 53 | 54 | Button(role: .destructive, action: { 55 | self.presentation.wrappedValue.dismiss() 56 | }) { 57 | Text("Cancel") 58 | .padding() 59 | } 60 | } 61 | .padding() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Circles/Views/Rooms/ScanQrCodeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScanQrCodeView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/9/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | import CodeScanner 11 | 12 | struct ScanQrCodeView: View { 13 | @Binding var roomId: RoomId? 14 | 15 | var body: some View { 16 | VStack { 17 | Text("Scan QR code to request invite") 18 | .font(.title) 19 | .padding() 20 | 21 | CodeScannerView(codeTypes: [.qr]) { response in 22 | if case let .success(result) = response { 23 | let scannedCode = result.string 24 | print("QR code scanning result = \(scannedCode)") 25 | if let scannedRoomId = RoomId(scannedCode) { 26 | print("QR code contains a valid roomId") 27 | self.roomId = scannedRoomId 28 | } else if let firstToken = scannedCode.split(separator: " ").first, 29 | let url = URL(string: firstToken.description) 30 | { 31 | print("QR code contains a valid URL \(url)") 32 | guard let host = url.host(), 33 | CIRCLES_DOMAINS.contains(host) 34 | else { 35 | print("QR code URL is not for one of our domains (found \(url.host() ?? "nil"))") 36 | return 37 | } 38 | for component in url.pathComponents { 39 | if let pathRoomId = RoomId(component) { 40 | print("Found roomId \(pathRoomId) in QR code URL path") 41 | self.roomId = pathRoomId 42 | return 43 | } 44 | } 45 | } else { 46 | print("QR does not contain a valid roomId: \(scannedCode)") 47 | } 48 | } else { 49 | print("QR code scanning failed") 50 | } 51 | } 52 | //.frame(width: 300, height: 300) 53 | //.padding() 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Circles/Views/Settings/DeactivateAccountCustomAlertView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeactivateAccountCustomAlertView.swift 3 | // Circles 4 | // 5 | // Created by Dmytro Ryshchuk on 5/21/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DeactivateAccountAlertModel { 11 | let userId: String 12 | let title: String 13 | let message: String 14 | } 15 | 16 | struct DeactivateAccountAlertView: View { 17 | @State private var text = "" 18 | var model: DeactivateAccountAlertModel 19 | var onConfirm: () -> Void 20 | var onCancel: () -> Void 21 | 22 | var body: some View { 23 | VStack { 24 | Text(model.title) 25 | .font(.headline) 26 | Text(model.message) 27 | .font(.subheadline) 28 | TextField(model.userId, text: $text) 29 | .textFieldStyle(RoundedBorderTextFieldStyle()) 30 | .padding() 31 | 32 | AsyncButton(role: .destructive, action: { 33 | onConfirm() 34 | }) { 35 | Text("Permanently deactivate") 36 | } 37 | .disabled(text != model.userId) 38 | 39 | Button("Cancel") { 40 | onCancel() 41 | } 42 | .padding() 43 | } 44 | .padding() 45 | .background(Color.white) 46 | .cornerRadius(12) 47 | .shadow(radius: 10) 48 | .padding(.horizontal, 60) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Circles/Views/Settings/DevicesScreen.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020, 2021 Kombucha Digital Privacy Systems LLC 2 | // 3 | // DevicesScreen.swift 4 | // Circles for iOS 5 | // 6 | // Created by Charles Wright on 2/26/21. 7 | // 8 | 9 | import SwiftUI 10 | import Matrix 11 | 12 | struct DevicesScreen: View { 13 | @ObservedObject var session: Matrix.Session 14 | 15 | 16 | var currentDeviceView: some View { 17 | VStack(alignment: .leading, spacing: 15) { 18 | if let dev = session.device { 19 | let myDeviceModel = UIDevice.current.model 20 | let iconName = myDeviceModel.components(separatedBy: .whitespaces).first?.lowercased() ?? SystemImages.desktopcomputer.rawValue 21 | Label("This \(myDeviceModel)", systemImage: iconName) 22 | .font(.headline) 23 | 24 | DeviceInfoView(session: session, device: dev) 25 | .padding(.leading) 26 | Divider() 27 | } 28 | } 29 | } 30 | 31 | var body: some View { 32 | Form { 33 | ForEach(session.devices, id: \.deviceId) { device in 34 | if !device.dehydrated { 35 | NavigationLink(destination: DeviceDetailsView(session: session, device: device)) { 36 | DeviceInfoView(session: session, device: device) 37 | } 38 | .buttonStyle(.plain) 39 | } 40 | } 41 | } 42 | .navigationTitle(Text("Active Login Sessions")) 43 | } 44 | } 45 | 46 | /* 47 | struct DevicesScreen_Previews: PreviewProvider { 48 | static var previews: some View { 49 | DevicesScreen() 50 | } 51 | } 52 | */ 53 | -------------------------------------------------------------------------------- /Circles/Views/Settings/IgnoredUsersView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IgnoredUsersView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/10/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct IgnoredUsersView: View { 12 | @ObservedObject var session: Matrix.Session 13 | 14 | var body: some View { 15 | Form { 16 | Section("Ignored Users") { 17 | let ignored = session.ignoredUserIds 18 | if ignored.isEmpty { 19 | Text("Not ignoring any users") 20 | .padding() 21 | } else { 22 | ForEach(ignored) { userId in 23 | let user = session.getUser(userId: userId) 24 | 25 | HStack { 26 | UserAvatarView(user: user) 27 | .frame(width: 60, height: 60) 28 | VStack(alignment: .leading) { 29 | UserNameView(user: user) 30 | Text(user.userId.stringValue) 31 | .font(.subheadline) 32 | .foregroundColor(.gray) 33 | } 34 | Spacer() 35 | AsyncButton(action: { 36 | try await user.session.unignoreUser(userId: user.userId) 37 | }) { 38 | Image(systemName: SystemImages.trash.rawValue) 39 | .foregroundColor(.red) 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | .navigationTitle(Text("Ignored Users")) 47 | } 48 | } 49 | 50 | /* 51 | struct IgnoredUsersView_Previews: PreviewProvider { 52 | static var previews: some View { 53 | IgnoredUsersView() 54 | } 55 | } 56 | */ 57 | -------------------------------------------------------------------------------- /Circles/Views/Settings/ProfileSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileSettingsView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/5/23. 6 | // 7 | 8 | import SwiftUI 9 | import PhotosUI 10 | import Matrix 11 | 12 | struct ProfileSettingsView: View { 13 | @ObservedObject var session: Matrix.Session 14 | //@ObservedObject var user: Matrix.User 15 | 16 | @State var showPicker = false 17 | @State var newAvatarImageItem: PhotosPickerItem? 18 | 19 | var body: some View { 20 | VStack { 21 | Form { 22 | HStack { 23 | Text("Profile picture") 24 | Spacer() 25 | 26 | PhotosPicker(selection: $newAvatarImageItem, matching: .images) { 27 | UserAvatarView(user: session.me) 28 | .frame(width: 80, height: 80) 29 | } 30 | .buttonStyle(.plain) 31 | .onChange(of: newAvatarImageItem) { _ in 32 | Task { 33 | if let data = try? await newAvatarImageItem?.loadTransferable(type: Data.self) { 34 | if let img = UIImage(data: data) { 35 | try await session.setMyAvatarImage(img) 36 | } 37 | } 38 | } 39 | } 40 | } 41 | NavigationLink(destination: UpdateDisplaynameView(session: session)) { 42 | Text("Your name") 43 | .badge(abbreviate(session.me.displayName)) 44 | } 45 | 46 | Text("User ID") 47 | .badge(session.creds.userId.stringValue) 48 | 49 | /* 50 | NavigationLink(destination: UpdateStatusMessageView(session: session)) { 51 | Text("Status message") 52 | .badge(session.statusMessage ?? "(none)") 53 | } 54 | */ 55 | } 56 | } 57 | .navigationTitle("Public Profile") 58 | } 59 | } 60 | 61 | /* 62 | struct ProfileSettingsView_Previews: PreviewProvider { 63 | static var previews: some View { 64 | ProfileSettingsView() 65 | } 66 | } 67 | */ 68 | -------------------------------------------------------------------------------- /Circles/Views/Settings/UpdateDisplaynameView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateDisplaynameView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/6/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct UpdateDisplaynameView: View { 12 | @ObservedObject var session: Matrix.Session 13 | @Environment(\.presentationMode) var presentation 14 | //@ObservedObject var user: Matrix.User 15 | @State var newDisplayname: String 16 | 17 | enum FocusField { 18 | case displayname 19 | } 20 | @FocusState var focus: FocusField? 21 | 22 | init(session: Matrix.Session) { 23 | self.session = session 24 | self._newDisplayname = State(wrappedValue: session.me.displayName ?? "") 25 | } 26 | 27 | var body: some View { 28 | VStack(alignment: .center, spacing: 100) { 29 | //Text("Update displayname for user \(user.userId.stringValue)") 30 | Spacer() 31 | 32 | HStack { 33 | TextField(abbreviate(session.me.displayName, textIfEmpty: ""), text: $newDisplayname, prompt: Text("Your name")) 34 | .textContentType(.name) 35 | .textFieldStyle(.roundedBorder) 36 | .focused($focus, equals: .displayname) 37 | .frame(width: 300, height: 40) 38 | .onAppear { 39 | self.focus = .displayname 40 | } 41 | 42 | Button(action: { 43 | self.newDisplayname = "" 44 | }) { 45 | Image(systemName: SystemImages.xmark.rawValue) 46 | .foregroundColor(.gray) 47 | } 48 | } 49 | 50 | AsyncButton(action: { 51 | try await session.setMyDisplayName(newDisplayname) 52 | self.presentation.wrappedValue.dismiss() 53 | }) { 54 | Text("Update") 55 | } 56 | 57 | Spacer() 58 | } 59 | .navigationTitle("Change Name") 60 | } 61 | } 62 | 63 | /* 64 | struct UpdateDisplaynameView_Previews: PreviewProvider { 65 | static var previews: some View { 66 | UpdateDisplaynameView() 67 | } 68 | } 69 | */ 70 | -------------------------------------------------------------------------------- /Circles/Views/Settings/UpdateStatusMessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateStatusMessageView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/6/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct UpdateStatusMessageView: View { 12 | @ObservedObject var session: Matrix.Session 13 | @Environment(\.presentationMode) var presentation 14 | //@ObservedObject var user: Matrix.User 15 | @State var newStatus = "" 16 | 17 | var body: some View { 18 | VStack(alignment: .center, spacing: 100) { 19 | //Text("Update displayname for user \(user.userId.stringValue)") 20 | 21 | Spacer() 22 | 23 | TextField(session.me.statusMessage ?? "", text: $newStatus, prompt: Text("New status message")) 24 | .frame(width: 300, height: 40) 25 | 26 | AsyncButton(action: { 27 | try await session.setMyStatus(message: newStatus) 28 | self.presentation.wrappedValue.dismiss() 29 | }) { 30 | Text("Update") 31 | } 32 | 33 | Spacer() 34 | } 35 | .padding() 36 | } 37 | } 38 | 39 | /* 40 | struct UpdateStatusMessageView_Previews: PreviewProvider { 41 | static var previews: some View { 42 | UpdateStatusMessageView() 43 | } 44 | } 45 | */ 46 | -------------------------------------------------------------------------------- /Circles/Views/Setup/CircleSetupInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircleSetupInfo.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 5/28/24. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | class CircleSetupInfo: ObservableObject, Identifiable { 12 | var name: String 13 | @Published var avatar: UIImage? 14 | 15 | var id: String { 16 | name 17 | } 18 | 19 | init(name: String, avatar: UIImage? = nil) { 20 | self.name = name 21 | self.avatar = avatar 22 | } 23 | 24 | @MainActor 25 | func setAvatar(_ img: UIImage) { 26 | self.avatar = img 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Circles/Views/Setup/SetupCircleCard.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Kombucha Digital Privacy Systems LLC 2 | // 3 | // SetupCircleCard.swift 4 | // Circles for iOS 5 | // 6 | // Created by Charles Wright on 5/24/21. 7 | // 8 | 9 | import SwiftUI 10 | import PhotosUI 11 | import Matrix 12 | 13 | struct SetupCircleCard: View { 14 | var matrix: Matrix.Session 15 | @ObservedObject var user: Matrix.User 16 | @ObservedObject var info: CircleSetupInfo 17 | 18 | //@State var showPicker = false 19 | @State var selectedItem: PhotosPickerItem? 20 | 21 | var body: some View { 22 | VStack(alignment: .leading) { 23 | HStack { 24 | ZStack { 25 | Color.gray 26 | 27 | if let img = info.avatar { 28 | Image(uiImage: img) 29 | .resizable() 30 | .scaledToFill() 31 | } 32 | } 33 | .clipShape(Circle()) 34 | .frame(width: 100, height: 100, alignment: .center) 35 | .foregroundColor(.gray) 36 | .overlay(alignment: .bottomTrailing) { 37 | PhotosPicker(selection: $selectedItem) { 38 | Image(systemName: SystemImages.pencilCircleFill.rawValue) 39 | .symbolRenderingMode(.multicolor) 40 | .font(.system(size: 30)) 41 | .foregroundColor(.accentColor) 42 | } 43 | .buttonStyle(.borderless) 44 | } 45 | 46 | VStack(alignment: .leading) { 47 | Text(info.name) 48 | .font( 49 | CustomFonts.nunito24 50 | .weight(.heavy) 51 | ) 52 | Text(self.user.displayName ?? "") 53 | .font(CustomFonts.nunito16) 54 | } 55 | .padding(.leading) 56 | } 57 | .onChange(of: selectedItem) { newItem in 58 | Task { 59 | if let data = try? await newItem?.loadTransferable(type: Data.self), 60 | let img = UIImage(data: data) 61 | { 62 | info.setAvatar(img) 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } 69 | 70 | /* 71 | struct SetupCircleCard_Previews: PreviewProvider { 72 | static var previews: some View { 73 | SetupCircleCard() 74 | } 75 | } 76 | */ 77 | -------------------------------------------------------------------------------- /Circles/Views/Setup/SetupIntroToCircles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SetupIntroToCircles.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 5/30/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SetupIntroToCircles: View { 11 | @Binding var stage: SetupScreen.Stage 12 | 13 | var body: some View { 14 | let elementWidth = UIScreen.main.bounds.width - 48 15 | let elementHeight: CGFloat = 48.0 16 | ZStack { 17 | Color.greyCool200 18 | 19 | VStack { 20 | CirclesHelpView() 21 | 22 | Button(action: { 23 | stage = .circlesSetup 24 | }) { 25 | Text("Next: Set up my circles") 26 | } 27 | .buttonStyle(BigRoundedButtonStyle(width: elementWidth, height: elementHeight)) 28 | .font( 29 | CustomFonts.nunito16 30 | .weight(.bold) 31 | ) 32 | .padding(.bottom, 38) 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Circles/Views/Setup/SetupScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SetupScreen.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 6/9/22. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct SetupScreen: View { 12 | var store: CirclesStore 13 | @ObservedObject var matrix: Matrix.Session 14 | 15 | enum Stage { 16 | case profileSetup 17 | case circlesIntro 18 | case circlesSetup 19 | } 20 | @State var stage: Stage = .profileSetup 21 | @State var displayName: String? 22 | 23 | var body: some View { 24 | switch stage { 25 | case .profileSetup: 26 | SetupAvatarView(matrix: matrix, displayName: $displayName, stage: $stage) 27 | .background(Color.greyCool200) 28 | 29 | case .circlesIntro: 30 | SetupIntroToCircles(stage: $stage) 31 | .background(Color.greyCool200) 32 | 33 | case .circlesSetup: 34 | SetupCirclesView(store: store, matrix: matrix, user: matrix.me) 35 | .background(Color.greyCool200) 36 | } 37 | } 38 | } 39 | 40 | /* 41 | struct SetupScreen_Previews: PreviewProvider { 42 | static var previews: some View { 43 | SetupScreen() 44 | } 45 | } 46 | */ 47 | -------------------------------------------------------------------------------- /Circles/Views/Signup/SignupStartForm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignupStartForm.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 9/8/21. 6 | // 7 | 8 | import SwiftUI 9 | import StoreKit 10 | import Matrix 11 | 12 | struct SignupStartForm: View { 13 | @ObservedObject var session: SignupSession 14 | var state: UIAA.SessionState 15 | @State private var canNotRegisterFlow: Bool = false 16 | 17 | var body: some View { 18 | VStack { 19 | Color.clear 20 | .onAppear { 21 | Task { 22 | if let freeFlow = state.flows.first(where: { flow in 23 | flow.stages.contains(AUTH_TYPE_FREE_SUBSCRIPTION) && 24 | !flow.stages.contains(AUTH_TYPE_GOOGLE_SUBSCRIPTION) && 25 | !flow.stages.contains(AUTH_TYPE_APPSTORE_SUBSCRIPTION) 26 | }) { 27 | await session.selectFlow(flow: freeFlow) 28 | } else { 29 | let appleFlow = state.flows.first(where: { 30 | $0.stages.contains(AUTH_TYPE_APPSTORE_SUBSCRIPTION) 31 | }) 32 | 33 | if appleFlow != nil { 34 | await session.selectFlow(flow: appleFlow!) 35 | } else { 36 | canNotRegisterFlow = true 37 | } 38 | } 39 | } 40 | } 41 | VStack { 42 | Label("We apologize, but new accounts are not currently available on this server. Please check back later.", systemImage: SystemImages.exclamationmarkTriangle.rawValue) 43 | .multilineTextAlignment(.center) 44 | .padding(.horizontal, 20) 45 | }.opacity(canNotRegisterFlow ? 1 : 0) 46 | } 47 | } 48 | } 49 | 50 | /* 51 | struct SignupStartForm_Previews: PreviewProvider { 52 | static var previews: some View { 53 | SignupScreen() 54 | } 55 | } 56 | */ 57 | -------------------------------------------------------------------------------- /Circles/Views/Timeline/LikeButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LikeButton.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/19/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct LikeButton: View { 12 | @ObservedObject var message: Matrix.Message 13 | 14 | var body: some View { 15 | let likers = message.reactions["❤️"] ?? [] 16 | let iLikedThisMessage = likers.contains(message.room.session.creds.userId) 17 | let iCanReact = message.room.iCanSendEvent(type: M_REACTION) 18 | 19 | AsyncButton(action: { 20 | // send ❤️ emoji reaction if we have not sent it yet 21 | // Otherwise retract it 22 | if iLikedThisMessage { 23 | // Redact the previous reaction message 24 | try await message.sendRemoveReaction("❤️") 25 | } else { 26 | // Send the reaction 27 | try await message.sendReaction("❤️") 28 | } 29 | }) { 30 | HStack(alignment: .center, spacing: 2) { 31 | let icon = iLikedThisMessage ? SystemImages.heartFill : SystemImages.heart 32 | let color = iLikedThisMessage ? Color.accentColor : Color.primary 33 | Image(systemName: icon.rawValue) 34 | .frame(width: 20, height: 20) 35 | .foregroundColor(color) 36 | Text("\(message.reactions["❤️"]?.count ?? 0)") 37 | } 38 | .font(Font.custom("Inter", size: 14).weight(.medium)) 39 | .foregroundColor(Color.greyCool1000) 40 | } 41 | .disabled(!iCanReact) 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /Circles/Views/Timeline/MessageAuthorHeader.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020, 2021 Kombucha Digital Privacy Systems LLC 2 | // 3 | // MessageAuthorHeader.swift 4 | // Circles for iOS 5 | // 6 | // Created by Charles Wright on 11/3/20. 7 | // 8 | 9 | import SwiftUI 10 | import Matrix 11 | 12 | struct MessageAuthorHeader: View { 13 | @ObservedObject var user: Matrix.User 14 | 15 | var body: some View { 16 | HStack(alignment: .center) { 17 | UserAvatarView(user: user) 18 | .frame(width: 45, height: 45) 19 | 20 | Text(user.displayName ?? user.userId.username) 21 | .font(.headline) 22 | .fontWeight(.semibold) 23 | .lineLimit(1) 24 | //.padding(1) 25 | } 26 | .onAppear { 27 | if user.avatarUrl == nil || user.displayName == nil { 28 | user.refreshProfile() 29 | } 30 | if user.avatar == nil && user.avatarUrl != nil { 31 | user.fetchAvatarImage() 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Circles/Views/Timeline/MessageSheetType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageSheetType.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/13/21. 6 | // 7 | 8 | import Foundation 9 | 10 | enum MessageSheetType: String { 11 | case edit 12 | case reporting 13 | case liked 14 | } 15 | 16 | extension MessageSheetType: Identifiable { 17 | var id: String { rawValue } 18 | } 19 | -------------------------------------------------------------------------------- /Circles/Views/Timeline/MessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/13/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Matrix 11 | 12 | protocol MessageView: View { 13 | var message: Matrix.Message { get } 14 | var isLocalEcho: Bool { get } 15 | var isThreaded: Bool { get } 16 | 17 | init(message: Matrix.Message, isLocalEcho: Bool, isThreaded: Bool) 18 | } 19 | -------------------------------------------------------------------------------- /Circles/Views/Timeline/StateEventView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StateEventView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 4/11/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Matrix 11 | 12 | struct StateEventView: View { 13 | var message: Matrix.Message 14 | var roomType: String = "room" 15 | 16 | var body: some View { 17 | VStack { 18 | let sender = message.sender.displayName ?? "\(message.sender.userId)" 19 | switch message.type { 20 | case M_ROOM_CREATE: 21 | Text("*\(roomType.capitalized) created by \(sender)*") 22 | 23 | case M_ROOM_AVATAR: 24 | Text("*\(sender) set a new cover image*") 25 | 26 | case M_ROOM_NAME: 27 | Text("*\(sender) set the \(roomType) name*") 28 | 29 | case M_ROOM_TOPIC: 30 | Text("*\(sender) set the \(roomType) topic*") 31 | 32 | case M_ROOM_MEMBER: 33 | if let content = message.content as? RoomMemberContent 34 | { 35 | switch content.membership { 36 | case .invite: 37 | Text("*\(sender) invited \(message.stateKey ?? "ERROR Unknown user")*") 38 | case .ban: 39 | Text("*\(sender) banned \(message.stateKey ?? "ERROR Unknown user")*") 40 | case .join: 41 | if message.sender.userId.description == message.stateKey { 42 | Text("*\(sender) joined*") 43 | } else if let otherUser = message.stateKey { 44 | Text("*\(sender) added \(otherUser)*") 45 | } else { 46 | Text("*\(sender) updated their public profile*") 47 | } 48 | case .knock: 49 | Text("*\(sender) knocked*") 50 | case .leave: 51 | if message.sender.userId.description == message.stateKey { 52 | Text("*\(sender) left*") 53 | } else { 54 | Text("*\(sender) kicked \(message.stateKey ?? "ERROR Unknown user")*") 55 | } 56 | } 57 | } else { 58 | Text("*\(sender) updated the \(roomType) state*") 59 | } 60 | case M_ROOM_ENCRYPTION: 61 | Text("*\(sender) set the room encryption parameters*") 62 | 63 | default: 64 | Text("*\(sender) updated the \(roomType) state (\(message.type))*") 65 | } 66 | } 67 | .font(.caption) 68 | .padding(2) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Circles/Views/Timeline/ThreadView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThreadView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/27/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct ThreadView: View { 12 | @ObservedObject var room: Matrix.Room 13 | var root: Matrix.Message 14 | var messages: [Matrix.Message] 15 | 16 | init(room: Matrix.Room, root: Matrix.Message) { 17 | self.room = room 18 | self.root = root 19 | // We need an ordered collection to use a ForEach in the View, so sort the Set into an Array 20 | if let thread: Set = room.threads[root.eventId] { 21 | self.messages = [root] + thread.sorted(by: {$0.timestamp < $1.timestamp} ) 22 | } else { 23 | self.messages = [root] 24 | } 25 | } 26 | 27 | var body: some View { 28 | VStack { 29 | ScrollView { 30 | ForEach(messages) { message in 31 | V(message: message, isLocalEcho: false, isThreaded: true) 32 | } 33 | 34 | if DebugModel.shared.debugMode { 35 | Text("Thread Id: \(root.eventId)") 36 | if let thread = room.threads[root.eventId] { 37 | Text("root + \(thread.count) messages in the thread") 38 | } else { 39 | Text("No thread found") 40 | } 41 | let check = room.messages.filter({ $0.eventId == root.eventId || $0.threadId == root.eventId }) 42 | Text("\(check.count) messages in the room that should have matched") 43 | } 44 | } 45 | } 46 | .navigationTitle(Text("Thread")) 47 | } 48 | } 49 | 50 | /* 51 | struct ThreadView_Previews: PreviewProvider { 52 | static var previews: some View { 53 | ThreadView() 54 | } 55 | } 56 | */ 57 | -------------------------------------------------------------------------------- /Circles/Views/Timeline/TimelineViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimelineViewModel.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/30/24. 6 | // 7 | 8 | import Foundation 9 | import Matrix 10 | 11 | public class TimelineViewModel: ObservableObject { 12 | @Published var scrollPosition: EventId? 13 | } 14 | -------------------------------------------------------------------------------- /Circles/Views/Timeline/UserAvatarView.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020, 2021 Kombucha Digital Privacy Systems LLC 2 | // 3 | // ProfileImageView.swift 4 | // Circles for iOS 5 | // 6 | // Created by Charles Wright on 11/3/20. 7 | // 8 | 9 | import SwiftUI 10 | import Matrix 11 | 12 | struct UserAvatarView: View { 13 | @ObservedObject var user: Matrix.User 14 | @Environment(\.colorScheme) var colorScheme 15 | 16 | var defaultImageColor: Color { 17 | colorScheme == .dark 18 | ? Color.white 19 | : Color.black 20 | } 21 | 22 | var body: some View { 23 | if let avatar = user.avatar { 24 | BasicImage(uiImage: avatar) 25 | .aspectRatio(contentMode: .fill) 26 | .clipShape(Circle()) 27 | } else { 28 | ZStack { 29 | let color = Color.background.randomColor(from: user.userId.stringValue) 30 | 31 | Image("") 32 | .resizable() 33 | .background(color) 34 | .aspectRatio(contentMode: .fit) 35 | .clipShape(Circle()) 36 | 37 | let userIdCharacter = user.userId.stringValue.dropFirst().first?.uppercased() 38 | 39 | Text(String(user.displayName?.first?.uppercased() ?? userIdCharacter ?? "")) 40 | .fontWeight(.bold) 41 | .font(.title3) 42 | .foregroundColor(.white) 43 | .shadow(color: .gray, radius: 1) 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Circles/Views/Timeline/UserNameView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserNameView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 10/15/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct UserNameView: View { 12 | @ObservedObject var user: Matrix.User 13 | 14 | var body: some View { 15 | Text(user.displayName ?? user.userId.username) 16 | .onAppear { 17 | user.refreshProfile() 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Circles/Views/UIA/BsspekeEnrollSaveForm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BsspekeEnrollSaveForm.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 3/31/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Matrix 11 | 12 | struct BsspekeEnrollSaveForm: View { 13 | var session: any UIASession 14 | 15 | var body: some View { 16 | VStack { 17 | Spacer() 18 | ProgressView { 19 | Text("Completing passphrase enrollment") 20 | } 21 | Spacer() 22 | } 23 | .onAppear { 24 | Task { 25 | try await session.doBSSpekeEnrollSaveStage() 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Circles/Views/UIA/EmailEnrollSubmitTokenForm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmailEnrollSubmitTokenForm.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 3/31/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Matrix 11 | 12 | struct EmailEnrollSubmitTokenForm: View { 13 | var session: any UIASession 14 | var secret: String 15 | 16 | @State var token = "" 17 | 18 | enum FocusField { 19 | case token 20 | } 21 | @FocusState var focus: FocusField? 22 | 23 | @State var showAlert = false 24 | let alertTitle = "Invalid code" 25 | let alertMessage = "Please double-check the code and enter it again, or request a new code." 26 | 27 | var tokenIsValid: Bool { 28 | token.count == 6 && Int(token) != nil 29 | } 30 | 31 | var body: some View { 32 | VStack { 33 | Spacer() 34 | VStack { 35 | Text("Enter the 6-digit code that you received in your email") 36 | } 37 | Spacer() 38 | VStack { 39 | TextField("123456", text: $token, prompt: Text("6-Digit Code")) 40 | .customEmailTextFieldStyle(contentType: .oneTimeCode, keyboardType: .numberPad) 41 | .textFieldStyle(RoundedBorderTextFieldStyle()) 42 | .focused($focus, equals: .token) 43 | .frame(width: 300.0, height: 40.0) 44 | .onAppear { 45 | self.focus = .token 46 | } 47 | 48 | AsyncButton(action: { 49 | do { 50 | try await session.doEmailEnrollSubmitTokenStage(token: token, secret: secret) 51 | } catch { 52 | print("Email submit token stage failed") 53 | self.showAlert = true 54 | } 55 | }) { 56 | Text("Submit") 57 | } 58 | .buttonStyle(BigRoundedButtonStyle()) 59 | .disabled(!tokenIsValid) 60 | .alert(isPresented: $showAlert) { 61 | Alert(title: Text(self.alertTitle), 62 | message: Text(self.alertMessage), 63 | dismissButton: .default(Text("OK"), action: { self.token = "" }) 64 | ) 65 | } 66 | 67 | AsyncButton(action: { 68 | try await session.redoEmailEnrollRequestTokenStage() 69 | }) { 70 | Text("Send a new code") 71 | .padding() 72 | } 73 | } 74 | Spacer() 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Circles/Views/UIA/EmailLoginSubmitTokenForm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmailLoginSubmitTokenForm.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 3/25/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Matrix 11 | 12 | struct EmailLoginSubmitTokenForm: View { 13 | var session: any UIASession 14 | var secret: String 15 | 16 | @State var token = "" 17 | 18 | enum FocusField { 19 | case token 20 | } 21 | @FocusState var focus: FocusField? 22 | 23 | @State var showAlert = false 24 | let alertTitle = "Invalid code" 25 | let alertMessage = "Please double-check the code and enter it again, or request a new code." 26 | 27 | var tokenIsValid: Bool { 28 | token.count == 6 && Int(token) != nil 29 | } 30 | 31 | var body: some View { 32 | VStack { 33 | Spacer() 34 | VStack { 35 | Text("Enter the 6-digit code that you received in your email") 36 | } 37 | Spacer() 38 | VStack { 39 | TextField("123456", text: $token, prompt: Text("6-Digit Code")) 40 | .customEmailTextFieldStyle(contentType: .oneTimeCode, keyboardType: .numberPad) 41 | .textFieldStyle(RoundedBorderTextFieldStyle()) 42 | .focused($focus, equals: .token) 43 | .frame(width: 300.0, height: 40.0) 44 | .onAppear { 45 | self.focus = .token 46 | } 47 | 48 | AsyncButton(action: { 49 | do { 50 | try await session.doEmailLoginSubmitTokenStage(token: token, secret: secret) 51 | } catch { 52 | print("Email login submit token stage failed") 53 | self.showAlert = true 54 | } 55 | }) { 56 | Text("Submit") 57 | } 58 | .buttonStyle(BigRoundedButtonStyle()) 59 | .disabled(!tokenIsValid) 60 | .alert(isPresented: $showAlert) { 61 | Alert(title: Text(self.alertTitle), 62 | message: Text(self.alertMessage), 63 | dismissButton: .default(Text("OK"), action: { self.token = "" }) 64 | ) 65 | } 66 | 67 | AsyncButton(action: { 68 | try await session.redoEmailLoginRequestTokenStage() 69 | }) { 70 | Text("Send a new code") 71 | .padding() 72 | } 73 | } 74 | Spacer() 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Circles/Views/UIA/FreeSubscriptionForm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeSubscriptionForm.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 1/3/24. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct FreeSubscriptionForm: View { 12 | var session: any UIASession 13 | 14 | var body: some View { 15 | VStack { 16 | Spacer() 17 | 18 | if let signup = session as? SignupSession { 19 | ProgressView("Registering free subscription with the server...") 20 | .task { 21 | try? await signup.doFreeSubscriptionStage() 22 | } 23 | } else { 24 | Label("Error: Free subscriptions are only available at registration time", systemImage: SystemImages.exclamationmarkTriangle.rawValue) 25 | } 26 | 27 | Spacer() 28 | } 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /Circles/Views/UIA/UiaOverlayView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UiaOverlayView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 7/10/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | // This type is necessary because we need a View that observes the Matrix Session, 12 | // in order to catch the change in its UIA session. 13 | // This view is used in the main ContentView in the app, to overlay on top of the 14 | // main tabbed interface for the app's normal operation. 15 | struct UiaOverlayView: View { 16 | @ObservedObject var circles: CirclesApplicationSession 17 | @ObservedObject var matrix: Matrix.Session 18 | 19 | var body: some View { 20 | if let uia = matrix.uiaSession, 21 | !uia.isFinished 22 | { 23 | //Color.gray.opacity(0.5) 24 | UiaView(session: circles, uia: uia) 25 | .frame(minWidth: 325, maxWidth: 500, maxHeight: 700, alignment: .center) 26 | .background(in: RoundedRectangle(cornerRadius: 10)) 27 | } 28 | } 29 | } 30 | 31 | /* 32 | struct UiaOverlayView_Previews: PreviewProvider { 33 | static var previews: some View { 34 | UiaOverlayView() 35 | } 36 | } 37 | */ 38 | -------------------------------------------------------------------------------- /Circles/Views/UIA/UiaView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UiaView.swift 3 | // Circles 4 | // 5 | // Created by Charles Wright on 6/22/23. 6 | // 7 | 8 | import SwiftUI 9 | import Matrix 10 | 11 | struct UiaView: View { 12 | var session: CirclesApplicationSession 13 | //var matrix: Matrix.Session 14 | @ObservedObject var uia: UIAuthSession 15 | 16 | var body: some View { 17 | VStack { 18 | Text("Authentication Required") 19 | .font( 20 | CustomFonts.nunito24 21 | .bold() 22 | ) 23 | .padding() 24 | 25 | Spacer() 26 | 27 | // cvw: Based on the SignupScreen 28 | switch uia.state { 29 | case .notConnected: 30 | AsyncButton(action: { 31 | try await uia.connect() 32 | }) { 33 | Text("Tap to Authenticate") 34 | } 35 | 36 | case .failed(_): // let error 37 | Text("Authentication failed") 38 | 39 | case .canceled: 40 | Text("Authentication canceled") 41 | 42 | case .connected(let uiaaState): 43 | ProgressView() 44 | .onAppear { 45 | // Choose a flow 46 | // FIXME: Just go with the first one for now 47 | if let flow = uiaaState.flows.first { 48 | _ = Task { await uia.selectFlow(flow: flow) } 49 | } 50 | } 51 | 52 | case .inProgress(let uiaaState, let stages): 53 | UiaInProgressView(session: uia, state: uiaaState, stages: stages) 54 | 55 | case .finished(_): // let data 56 | Text("Success!") 57 | .onAppear { 58 | _ = Task { 59 | try await session.cancelUIA() 60 | } 61 | } 62 | } 63 | } 64 | .safeAreaInset(edge: .bottom) { 65 | AsyncButton(role: .destructive, action: { 66 | try await session.cancelUIA() 67 | }) { 68 | Text("Cancel") 69 | .padding() 70 | } 71 | } 72 | .padding(.horizontal) 73 | } 74 | } 75 | 76 | /* 77 | struct UiaView_Previews: PreviewProvider { 78 | static var previews: some View { 79 | UiaView() 80 | } 81 | } 82 | */ 83 | -------------------------------------------------------------------------------- /Circles/Views/View+Extensions/ButtonStyle+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ButtonStyle+Extensions.swift 3 | // Circles 4 | // 5 | // Created by Dmytro Ryshchuk on 6/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BigRoundedButtonStyle: ButtonStyle { 11 | var width: CGFloat = 300.0 12 | var height: CGFloat = 40.0 13 | var color: Color = .accentColor 14 | var borderWidth: CGFloat = 0 15 | var textColor: Color = .white 16 | 17 | func makeBody(configuration: Configuration) -> some View { 18 | configuration.label 19 | .padding() 20 | .frame(width: width, height: height) 21 | .overlay( 22 | RoundedRectangle(cornerRadius: 10) 23 | .stroke(Color.white, lineWidth: borderWidth) 24 | ) 25 | .foregroundColor(textColor) 26 | .background(configuration.isPressed ? color.opacity(0.8) : color) 27 | .cornerRadius(10) 28 | .scaleEffect(configuration.isPressed ? 0.95 : 1.0) 29 | } 30 | } 31 | 32 | struct PillButtonStyle: ButtonStyle { 33 | func makeBody(configuration: Configuration) -> some View { 34 | configuration.label 35 | .padding(.vertical, 5) 36 | .padding(.horizontal, 10) 37 | .foregroundColor(.white) 38 | .background(configuration.isPressed ? Color.accentColor.opacity(0.8) : Color.accentColor) 39 | .cornerRadius(16) 40 | .scaleEffect(configuration.isPressed ? 0.95 : 1.0) 41 | } 42 | } 43 | 44 | struct ReactionsButtonStyle: ButtonStyle { 45 | var buttonColor: Color 46 | 47 | func makeBody(configuration: Configuration) -> some View { 48 | configuration.label 49 | .padding([.top, .bottom], 7) 50 | .padding([.leading, .trailing], 12) 51 | .overlay( 52 | RoundedRectangle(cornerRadius: 20) 53 | .stroke(buttonColor, lineWidth: 2) 54 | ) 55 | .foregroundColor(.gray) 56 | .clipShape(RoundedRectangle(cornerRadius: 20)) 57 | .scaleEffect(configuration.isPressed ? 0.65 : 1.0) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Circles/Views/View+Extensions/View+Extentions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Extentions.swift 3 | // Circles 4 | // 5 | // Created by Dmytro Ryshchuk on 6/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BasicImage: View { 11 | var uiImage: UIImage? = nil 12 | var systemName: String? = nil 13 | var name: String? = nil 14 | var aspectRatio: ContentMode = .fit 15 | 16 | var body: some View { 17 | if let uiImage { 18 | Image(uiImage: uiImage) 19 | .resizable() 20 | .aspectRatio(contentMode: aspectRatio) 21 | } else if let systemName { 22 | Image(systemName: systemName) 23 | .resizable() 24 | .aspectRatio(contentMode: aspectRatio) 25 | } else if let name { 26 | Image(name) 27 | .resizable() 28 | .aspectRatio(contentMode: aspectRatio) 29 | } else { 30 | Image(systemName: SystemImages.xmarkIcloudFill.rawValue) 31 | .resizable() 32 | .aspectRatio(contentMode: aspectRatio) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Circles/fonts/Inter-VariableFont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/Circles/fonts/Inter-VariableFont.ttf -------------------------------------------------------------------------------- /Circles/fonts/Nunito-VariableFont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/Circles/fonts/Nunito-VariableFont.ttf -------------------------------------------------------------------------------- /FUTO Circles.storekit: -------------------------------------------------------------------------------- 1 | { 2 | "identifier" : "D07CD12F", 3 | "nonRenewingSubscriptions" : [ 4 | 5 | ], 6 | "products" : [ 7 | 8 | ], 9 | "settings" : { 10 | "_applicationInternalID" : "6451446720", 11 | "_developerTeamID" : "2W7AC6T8T5", 12 | "_failTransactionsEnabled" : false, 13 | "_lastSynchronizedDate" : 723742617.266168, 14 | "_locale" : "en_US", 15 | "_storefront" : "USA", 16 | "_storeKitErrors" : [ 17 | { 18 | "current" : null, 19 | "enabled" : false, 20 | "name" : "Load Products" 21 | }, 22 | { 23 | "current" : null, 24 | "enabled" : false, 25 | "name" : "Purchase" 26 | }, 27 | { 28 | "current" : null, 29 | "enabled" : false, 30 | "name" : "Verification" 31 | }, 32 | { 33 | "current" : null, 34 | "enabled" : false, 35 | "name" : "App Store Sync" 36 | }, 37 | { 38 | "current" : null, 39 | "enabled" : false, 40 | "name" : "Subscription Status" 41 | }, 42 | { 43 | "current" : null, 44 | "enabled" : false, 45 | "name" : "App Transaction" 46 | }, 47 | { 48 | "current" : null, 49 | "enabled" : false, 50 | "name" : "Manage Subscriptions Sheet" 51 | }, 52 | { 53 | "current" : null, 54 | "enabled" : false, 55 | "name" : "Refund Request Sheet" 56 | }, 57 | { 58 | "current" : null, 59 | "enabled" : false, 60 | "name" : "Offer Code Redeem Sheet" 61 | } 62 | ] 63 | }, 64 | "subscriptionGroups" : [ 65 | { 66 | "id" : "21418018", 67 | "localizations" : [ 68 | 69 | ], 70 | "name" : "Online Accounts", 71 | "subscriptions" : [ 72 | { 73 | "adHocOffers" : [ 74 | 75 | ], 76 | "codeOffers" : [ 77 | 78 | ], 79 | "displayPrice" : "1.99", 80 | "familyShareable" : false, 81 | "groupNumber" : 1, 82 | "internalID" : "6473546488", 83 | "introductoryOffer" : null, 84 | "localizations" : [ 85 | { 86 | "description" : "A Circles account with 10GB secure storage", 87 | "displayName" : "Individual - Monthly", 88 | "locale" : "en_US" 89 | } 90 | ], 91 | "productID" : "org.futo.circles.individual_monthly", 92 | "recurringSubscriptionPeriod" : "P1M", 93 | "referenceName" : "Individual Monthly", 94 | "subscriptionGroupID" : "21418018", 95 | "type" : "RecurringSubscription" 96 | } 97 | ] 98 | } 99 | ], 100 | "version" : { 101 | "major" : 3, 102 | "minor" : 0 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /NSE/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // NSE 4 | // 5 | // Created by Charles Wright on 3/8/24. 6 | // 7 | 8 | import Foundation 9 | 10 | let CIRCLES_APP_GROUP_NAME = "group.2W7AC6T8T5.org.futo.circles" 11 | 12 | let APP_PREFIX = "org.futo" 13 | let ROOM_TYPE_CIRCLE = APP_PREFIX+".social.timeline" 14 | let ROOM_TYPE_GROUP = APP_PREFIX+".social.group" 15 | let ROOM_TYPE_PHOTOS = APP_PREFIX+".social.gallery" 16 | let ROOM_TYPE_PROFILE = "m.space" 17 | let ROOM_TYPE_SPACE = "m.space" 18 | -------------------------------------------------------------------------------- /NSE/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionPointIdentifier 8 | com.apple.usernotifications.service 9 | NSExtensionPrincipalClass 10 | $(PRODUCT_MODULE_NAME).NotificationService 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /NSE/NSE.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.RX3RM99NR6.org.futo.circles 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Circles 2 | Circles was an end-to-end encrypted social network app with the goal of enabling 3 | friends and families to securely share stories and photos while 4 | safeguarding security and privacy. 5 | 6 | Prototype mobile apps for [Android](https://gitlab.futo.org/circles/circles-android) and [iOS](https://github.com/cvwright/circles-ios) were developed at FUTO between 2022 and 2024. 7 | Unfortunately it became apparent that making this project into a successful commercial product would require a level of effort far beyond what was feasible for our small team. 8 | Active development wrapped up in late 2024, and the code is now available under a very liberal Open Source license to allow for new forks or derivative works. 9 | 10 | Circles is built on [Matrix](https://matrix.org/), and as such, it inherits many nice 11 | properties from Matrix, including: 12 | * Federation - Anyone can run their own server, and users on different servers can communicate with each other seamlessly. 13 | * Open APIs and data formats - Circles uses standard Matrix message types, and it works 14 | with any spec-compliant Matrix server. 15 | * Security - Circles offers the same security guarantees as Matrix, using the same 16 | E2E encryption code as in Element and other popular Matrix clients. 17 | 18 | At the same time, anyone hoping to revive or fork this project should be 19 | aware of some important limitations from Matrix: 20 | * The standard Matrix `/login` API exposes the user's password to their homeserver; 21 | this almost certainly gives the homeserver a huge head start on cracking the user's *other* 22 | password, which Matrix uses to protect all of the user's encrypted secrets on the server. 23 | This is insecure in pratice because most human users will not bother to remember two 24 | distinct passwords for the same account. 25 | (We developed a custom authorization framework and implemented the BS-SPEKE PAKE protocol 26 | to work around this for Circles, but it requires running a custom authorization service 27 | on the homeserver.) 28 | * Matrix does not cryptographically verify room membership. 29 | Instead, clients like Circles must trust the server to tell them which user accounts are 30 | in each room. 31 | Then when the client sends a message, it provides each of those accounts with the decryption key. 32 | A malicious server can lie to the client about who is in the room, causing it to send the 33 | key to an adversary. 34 | (We did some preliminary work to help with this one, via MSC3917, but the changes 35 | required are extensive, our time was limited, and unfortunately this does not seem to be 36 | a top priority for anyone else in the community.) 37 | * Getting good performance out of Matrix servers is something of a dark art, and scalability 38 | would have been a challenge. 39 | We expected that we would need to write our own server if we wanted to grow the product. 40 | -------------------------------------------------------------------------------- /assets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/assets/.DS_Store -------------------------------------------------------------------------------- /assets/images/circles-and-groups.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/assets/images/circles-and-groups.jpeg -------------------------------------------------------------------------------- /assets/images/circles-screenshots.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/assets/images/circles-screenshots.jpeg -------------------------------------------------------------------------------- /assets/images/groups-screenshots.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/assets/images/groups-screenshots.jpeg -------------------------------------------------------------------------------- /assets/images/kickstarter-logo-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/assets/images/kickstarter-logo-green.png -------------------------------------------------------------------------------- /assets/images/photogallery-screenshots.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circles-project/circles-ios/5aec0b5cdb379fe13113ec55f4475a020b9c1834/assets/images/photogallery-screenshots.jpeg --------------------------------------------------------------------------------