Repository: tigase/siskin-im Branch: master Commit: d77818f97597 Files: 348 Total size: 2.9 MB Directory structure: gitextract_t2pcpgpq/ ├── .bartycrouch.toml ├── .github/ │ ├── FUNDING.yml │ └── ISSUE_TEMPLATE/ │ ├── bug_report_developer.md │ ├── bug_report_user.md │ ├── feature_request.md │ └── question.md ├── .gitignore ├── COPYING ├── Documentation/ │ ├── css/ │ │ └── docbook-xsl.css │ ├── index.asciidoc │ ├── restructured/ │ │ ├── .readthedocs.yaml │ │ ├── Advanced_Options.rst │ │ ├── Makefile │ │ ├── Tigase_Messenger_iOS.rst │ │ ├── Welcome.rst │ │ ├── conf.py │ │ ├── index.rst │ │ ├── locale/ │ │ │ ├── pl/ │ │ │ │ └── LC_MESSAGES/ │ │ │ │ └── siskin_im_translation.po │ │ │ └── zh_CN/ │ │ │ └── LC_MESSAGES/ │ │ │ └── siskin_im_translation.po │ │ └── make.bat │ └── text/ │ ├── advanced.asciidoc │ ├── interface.asciidoc │ └── welcome.asciidoc ├── NotificationService/ │ ├── Info.plist │ ├── NotificationService.entitlements │ └── NotificationService.swift ├── README.md ├── Shared/ │ ├── Info.plist │ ├── NotificationCategory.swift │ ├── Shared.h │ ├── database/ │ │ ├── ConversationType.swift │ │ └── Database.swift │ ├── notifications/ │ │ ├── ConversationNotifications.swift │ │ ├── NotificationEncryptionKeys.swift │ │ └── NotificationsManagerHelper.swift │ ├── ui/ │ │ └── UIImage.swift │ └── util/ │ ├── HTTPFileUploadHelper.swift │ ├── ImageQuality.swift │ ├── MediaHelper.swift │ ├── VideoQuality.swift │ └── crypto/ │ ├── Cipher+AES.swift │ ├── SSLCertificate.swift │ ├── SSLContext.swift │ └── SSLProcessor.swift ├── SiskinIM/ │ ├── AppDelegate.swift │ ├── Assets.xcassets/ │ │ ├── AppIcon-Simple.appiconset/ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── appLogo.imageset/ │ │ │ └── Contents.json │ │ ├── appearance/ │ │ │ ├── Contents.json │ │ │ ├── chatMessageText.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── chatslistBackground.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── chatslistItemSecondaryLabel.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── chatslistSemiBackground.colorset/ │ │ │ │ └── Contents.json │ │ │ └── tintColor.colorset/ │ │ │ └── Contents.json │ │ ├── audioCall.imageset/ │ │ │ └── Contents.json │ │ ├── defaultAvatar.imageset/ │ │ │ └── Contents.json │ │ ├── defaultGroupchatAvatar.imageset/ │ │ │ └── Contents.json │ │ ├── endCall.imageset/ │ │ │ └── Contents.json │ │ ├── first.imageset/ │ │ │ └── Contents.json │ │ ├── message.fill.imageset/ │ │ │ └── Contents.json │ │ ├── messageArchiving.imageset/ │ │ │ └── Contents.json │ │ ├── mute.imageset/ │ │ │ └── Contents.json │ │ ├── participants.imageset/ │ │ │ └── Contents.json │ │ ├── person.crop.circle.fill.imageset/ │ │ │ └── Contents.json │ │ ├── pushNotifications.imageset/ │ │ │ └── Contents.json │ │ ├── qrCodeBackground.colorset/ │ │ │ └── Contents.json │ │ ├── qrCodeForeground.colorset/ │ │ │ └── Contents.json │ │ ├── second.imageset/ │ │ │ └── Contents.json │ │ ├── switchCamera.imageset/ │ │ │ └── Contents.json │ │ ├── tigaseLogo.imageset/ │ │ │ └── Contents.json │ │ └── videoCall.imageset/ │ │ └── Contents.json │ ├── Info.plist │ ├── SiskinIM-Bridging-Header.h │ ├── bookmarks/ │ │ ├── BookmarkItem.swift │ │ ├── BookmarkViewCell.swift │ │ └── BookmarksController.swift │ ├── channel/ │ │ ├── ChannelBlockedUsersController.swift │ │ ├── ChannelCreateViewController.swift │ │ ├── ChannelEditInfoController.swift │ │ ├── ChannelInviteController.swift │ │ ├── ChannelJoinViewController.swift │ │ ├── ChannelParticipantsController.swift │ │ ├── ChannelSelectAccountAndComponentController.swift │ │ ├── ChannelSelectNewOwnerViewController.swift │ │ ├── ChannelSelectToJoinViewController.swift │ │ ├── ChannelSettingsViewController.swift │ │ ├── ChannelViewController.swift │ │ └── ChannelsHelper.swift │ ├── chat/ │ │ ├── BaseChatViewController+Share.swift │ │ ├── BaseChatViewController+ShareFile.swift │ │ ├── BaseChatViewController+ShareMedia.swift │ │ ├── BaseChatViewController.swift │ │ ├── BaseChatViewControllerWithDataSource.swift │ │ ├── BaseChatViewControllerWithDataSourceContextMenuAndToolbar.swift │ │ ├── ChatAttachementsCellView.swift │ │ ├── ChatAttachementsController.swift │ │ ├── ChatViewController.swift │ │ ├── ChatViewInputBar.swift │ │ ├── ShareLocationController.swift │ │ └── ShareLocationSearchResultsController.swift │ ├── chats/ │ │ ├── ChatsListTableViewCell.swift │ │ └── ChatsListViewController.swift │ ├── contacts/ │ │ ├── ContactBasicTableViewCell.swift │ │ ├── ContactFormTableViewCell.swift │ │ ├── ContactViewController.swift │ │ └── OMEMOIdentityTableViewCell.swift │ ├── conversation/ │ │ ├── AttachmentChatTableViewCell.swift │ │ ├── BaseChatTableViewCell.swift │ │ ├── ChatTableViewCell.swift │ │ ├── ChatTableViewMarkerCell.swift │ │ ├── ChatTableViewSystemCell.swift │ │ ├── ConversationDataSource.swift │ │ ├── ConversationLogController.swift │ │ ├── InvitationChatTableViewCell.swift │ │ ├── LinkPreviewChatTableViewCell.swift │ │ └── LocationChatTableViewCell.swift │ ├── database/ │ │ ├── DBCapabilitiesCache.swift │ │ ├── DBChatHistoryStore.swift │ │ ├── DBChatHistorySyncStore.swift │ │ ├── DBChatMarkersStore.swift │ │ ├── DBChatStore+ChannelStore.swift │ │ ├── DBChatStore+ChatStore.swift │ │ ├── DBChatStore+RoomStore.swift │ │ ├── DBChatStore.swift │ │ ├── DBOMEMOStore.swift │ │ ├── DBRosterStore.swift │ │ ├── DBVCardStore.swift │ │ ├── Database.swift │ │ ├── DatabaseMigrator.swift │ │ ├── MessageState.swift │ │ └── model/ │ │ ├── DisplayableIdProtocol.swift │ │ ├── conversations/ │ │ │ ├── AccountConversations.swift │ │ │ ├── Channel.swift │ │ │ ├── Chat.swift │ │ │ ├── Conversation.swift │ │ │ ├── ConversationBase.swift │ │ │ └── Room.swift │ │ └── history/ │ │ ├── AppendixProtocol.swift │ │ ├── ConversationAttachment.swift │ │ ├── ConversationEntry.swift │ │ ├── ConversationEntryEncryption.swift │ │ ├── ConversationEntryRecipient.swift │ │ ├── ConversationEntrySender.swift │ │ ├── ConversationEntryState.swift │ │ ├── ConversationInvitation.swift │ │ └── ConversationKey.swift │ ├── db-schema-1.sql │ ├── db-schema-10.sql │ ├── db-schema-11.sql │ ├── db-schema-12.sql │ ├── db-schema-13.sql │ ├── db-schema-14.sql │ ├── db-schema-2.sql │ ├── db-schema-3.sql │ ├── db-schema-4.sql │ ├── db-schema-5.sql │ ├── db-schema-6.sql │ ├── db-schema-7.sql │ ├── db-schema-8.sql │ ├── db-schema-9.sql │ ├── groupchat/ │ │ ├── InviteViewController.swift │ │ ├── MucChatOccupantsTableViewCell.swift │ │ ├── MucChatOccupantsTableViewController.swift │ │ ├── MucChatSettingsViewController.swift │ │ └── MucChatViewController.swift │ ├── localization/ │ │ ├── Base.lproj/ │ │ │ ├── Account.storyboard │ │ │ ├── Conversation.storyboard │ │ │ ├── Groupchat.storyboard │ │ │ ├── Info.storyboard │ │ │ ├── LaunchScreen.storyboard │ │ │ ├── MIX.storyboard │ │ │ ├── Main.storyboard │ │ │ ├── Settings.storyboard │ │ │ └── VoIP.storyboard │ │ ├── de.lproj/ │ │ │ ├── Account.strings │ │ │ ├── Conversation.strings │ │ │ ├── Groupchat.strings │ │ │ ├── Info.strings │ │ │ ├── LaunchScreen.strings │ │ │ ├── Localizable.strings │ │ │ ├── MIX.strings │ │ │ ├── Main.strings │ │ │ ├── Settings.strings │ │ │ └── VoIP.strings │ │ ├── en.lproj/ │ │ │ ├── Account.strings │ │ │ ├── Conversation.strings │ │ │ ├── Groupchat.strings │ │ │ ├── Info.strings │ │ │ ├── Localizable.strings │ │ │ ├── MIX.strings │ │ │ ├── Main.strings │ │ │ ├── Settings.strings │ │ │ └── VoIP.strings │ │ ├── es.lproj/ │ │ │ ├── Account.strings │ │ │ ├── Conversation.strings │ │ │ ├── Groupchat.strings │ │ │ ├── Info.strings │ │ │ ├── LaunchScreen.strings │ │ │ ├── Localizable.strings │ │ │ ├── MIX.strings │ │ │ ├── Main.strings │ │ │ ├── Settings.strings │ │ │ └── VoIP.strings │ │ └── pl.lproj/ │ │ ├── Account.strings │ │ ├── Conversation.strings │ │ ├── Groupchat.strings │ │ ├── Info.strings │ │ ├── LaunchScreen.strings │ │ ├── Localizable.strings │ │ ├── MIX.strings │ │ ├── Main.strings │ │ ├── Settings.strings │ │ └── VoIP.strings │ ├── notifications/ │ │ ├── NotificationCenterDelegate.swift │ │ └── NotificationManager.swift │ ├── roster/ │ │ ├── AbstractRosterViewController.swift │ │ ├── RosterItemEditViewController.swift │ │ ├── RosterItemTableViewCell.swift │ │ ├── RosterProvider.swift │ │ ├── RosterProviderFlat.swift │ │ ├── RosterProviderGrouped.swift │ │ └── RosterViewController.swift │ ├── service/ │ │ ├── AvatarEventHandler.swift │ │ ├── BlockedEventHandler.swift │ │ ├── DNSSrvDiskCache.swift │ │ ├── MeetEventHandler.swift │ │ ├── MessageEventHandler.swift │ │ ├── MixEventHandler.swift │ │ ├── MucEventHandler.swift │ │ ├── NewFeaturesDetector.swift │ │ ├── PresenceRosterEventHandler.swift │ │ ├── PushEventHandler.swift │ │ ├── StreamFeaturesCache.swift │ │ ├── XMPPClient_extension.swift │ │ ├── XmppService.swift │ │ └── XmppServiceEventHandler.swift │ ├── settings/ │ │ ├── AccountConnectivitySettingsViewController.swift │ │ ├── AccountDomainTableViewCell.swift │ │ ├── AccountQRCodeController.swift │ │ ├── AccountSettingsViewController.swift │ │ ├── AccountTableViewCell.swift │ │ ├── AddAccountController.swift │ │ ├── BlockedContactsController.swift │ │ ├── ChatSettingsViewController.swift │ │ ├── ContactsSettingsViewController.swift │ │ ├── DeviceMemoryUsageTableViewCell.swift │ │ ├── ExperimentalSettingsViewController.swift │ │ ├── MediaSettingsVIewController.swift │ │ ├── NotificationSettingsViewController.swift │ │ ├── OMEMOFingerprintsController.swift │ │ ├── RegisterAccountController.swift │ │ ├── ServerFeaturesViewController.swift │ │ ├── ServerSelectorTableViewCell.swift │ │ ├── SetAccountSettingsController.swift │ │ ├── SettingsViewController.swift │ │ ├── SetupViewController.swift │ │ └── server_features_list.xml │ ├── ui/ │ │ ├── AboutController.swift │ │ ├── AvatarStatusView.swift │ │ ├── AvatarView.swift │ │ ├── CertificateErrorAlert.swift │ │ ├── ChartView.swift │ │ ├── ChatBottomView.swift │ │ ├── CustomTabBarController.swift │ │ ├── DataFormController.swift │ │ ├── EmptyViewController.swift │ │ ├── EnumTableViewCell.swift │ │ ├── GetInTouchViewController.swift │ │ ├── GlobalSplitViewController.swift │ │ ├── MainTabBarController.swift │ │ ├── Markdown.swift │ │ ├── MessageTextView.swift │ │ ├── NavigationControllerWrappingSegue.swift │ │ ├── RoundButton.swift │ │ ├── StepperTableViewCell.swift │ │ ├── SwitchTableViewCell.swift │ │ ├── TablePicketViewController.swift │ │ └── suggestions/ │ │ └── MultiContactSelectionView.swift │ ├── util/ │ │ ├── AccountManager.swift │ │ ├── AccountManagerScramSaltedPasswordCache.swift │ │ ├── AppStoryboard.swift │ │ ├── Array+IndexChanges.swift │ │ ├── AudioSession.swift │ │ ├── AvatarManager.swift │ │ ├── AvatarStore.swift │ │ ├── ContactManager.swift │ │ ├── CurrentDatePublisher.swift │ │ ├── DownloadManager.swift │ │ ├── DownloadStore.swift │ │ ├── InvitationsManager.swift │ │ ├── MainNotificationManagerProvider.swift │ │ ├── MediaHelper.swift │ │ ├── MessageEncryption.swift │ │ ├── MetadataCache.swift │ │ ├── OSLog.swift │ │ ├── OpenSSL_AES_GCM_Engine.swift │ │ ├── PresenceStore.swift │ │ ├── ServerCertificateInfo.swift │ │ ├── Settings.swift │ │ ├── SiskinPushNotificationsModuleProvider.swift │ │ ├── TasksQueue.swift │ │ ├── UIColor_mix.swift │ │ ├── VCardManager.swift │ │ └── combine/ │ │ ├── Publisher+OnlyGetter.swift │ │ ├── Publisher+ThrottleFixed.swift │ │ └── Publisher+ThrottledSink.swift │ ├── vcard/ │ │ ├── VCardAvatarEditCell.swift │ │ ├── VCardEditAddressTableViewCell.swift │ │ ├── VCardEditEmailTableViewCell.swift │ │ ├── VCardEditPhoneTableViewCell.swift │ │ ├── VCardEditViewController.swift │ │ ├── VCardEntryTypeAwareTableViewCell.swift │ │ └── VCardTextEditCell.swift │ ├── voip/ │ │ ├── CallManager.swift │ │ ├── CameraPreviewView.swift │ │ ├── CreateMeetingViewController.swift │ │ ├── ExternalServiceDiscovery_Service_extension.swift │ │ ├── InviteToMeetingViewController.swift │ │ ├── JingleManager.swift │ │ ├── JingleManager_Session.swift │ │ ├── MeetController.swift │ │ ├── MeetManager.swift │ │ ├── RTCCameraVideoCapturer_Format.swift │ │ └── VideoCallController.swift │ └── xmpp/ │ ├── HttpFileUploadModule.swift │ └── SiskinPushNotificationsModule.swift ├── SiskinIM - Share/ │ ├── Assets.xcassets/ │ │ ├── AppIcon-Simple.appiconset/ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Info.plist │ ├── ShareViewController.swift │ ├── SiskinIM - Share.entitlements │ └── localization/ │ ├── Base.lproj/ │ │ └── MainInterface.storyboard │ ├── de.lproj/ │ │ └── MainInterface.strings │ ├── en.lproj/ │ │ └── MainInterface.strings │ ├── es.lproj/ │ │ └── MainInterface.strings │ └── pl.lproj/ │ └── MainInterface.strings ├── SiskinIM.entitlements ├── SiskinIM.xcodeproj/ │ ├── project.pbxproj │ ├── project.xcworkspace/ │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata/ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata/ │ └── xcschemes/ │ └── NotificationService.xcscheme ├── pom.xml ├── siskin-im.doap ├── swiftScript.swift ├── trim.sh └── update-frameworks.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .bartycrouch.toml ================================================ [update] tasks = ["interfaces", "code", "normalize"] [update.interfaces] paths = ["."] defaultToBase = true ignoreEmptyStrings = true unstripped = false [update.code] codePaths = ["."] localizablePaths = ["SiskinIM/localization"] defaultToKeys = true additive = true unstripped = false plistArguments = true [update.normalize] paths = ["."] sourceLocale = "en" harmonizeWithSource = true sortByKeys = true [lint] paths = ["."] duplicateKeys = true emptyValues = true ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [tigase] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report_developer.md ================================================ --- name: Bug report (Developer) about: Reports from app developers title: '' labels: 'bug' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Open '....' 3. Do '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Details (please complete the following information):** - Siskin Version: [e.g. 6.0.0] - Branch: [e.g. master] - Xcode version: [e.g. 11] - iOS version [e.g. 11.0] - iPhone model [e.g. iPhone 11] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report_user.md ================================================ --- name: Bug report (App user) about: Reports for app download from App Store title: '' labels: 'bug' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Open '....' 3. Click on '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Details (please complete the following information):** - Siskin Version: [e.g. 6.0.0] - iOS version [e.g. 11.0] - iPhone model [e.g. iPhone 11] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea to improve SiskinIM title: '' labels: 'enhancement' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/ISSUE_TEMPLATE/question.md ================================================ --- name: General question about: Just a question title: '' labels: 'question' assignees: '' --- **I have a problem with…** A clear and concise description of what the problem is. **Details (please complete the following information):** - Tigase version: [e.g. 8.1.0] - JVM flavour and version [e.g. AdoptOpenJDK11] - Operating system/distribution/version [e.g. Linux Ubuntu 20.04] ================================================ FILE: .gitignore ================================================ docs Frameworks ######################### # **.gitignore** file for Xcode4 / OS X Source projects # # NB: if you are storing "built" products, this WILL NOT WORK, # and you should use a different **.gitignore** (or none at all) # This file is for SOURCE projects, where there are many extra # files that we want to exclude # # For updates, see: http://stackoverflow.com/questions/49478/git-ignore-file-for-xcode-projects ######################### ##### # OS X temporary files that should never be committed .DS_Store *.swp profile #### # Xcode temporary files that should never be committed # # NB: NIB/XIB files still exist even on Storyboard projects, so we want this... *~.nib #### # Xcode build files - # # NB: slash on the end, so we only remove the FOLDER, not any files that were badly named "DerivedData" DerivedData/ # NB: slash on the end, so we only remove the FOLDER, not any files that were badly named "build" build/ ##### # Xcode private settings (window sizes, bookmarks, breakpoints, custom executables, smart groups) # # This is complicated: # # SOMETIMES you need to put this file in version control. # Apple designed it poorly - if you use "custom executables", they are # saved in this file. # 99% of projects do NOT use those, so they do NOT want to version control this file. # ..but if you're in the 1%, comment out the line "*.pbxuser" *.pbxuser *.mode1v3 *.mode2v3 *.perspectivev3 # NB: also, whitelist the default ones, some projects need to use these !default.pbxuser !default.mode1v3 !default.mode2v3 !default.perspectivev3 #### # Xcode 4 - semi-personal settings, often included in workspaces # # You can safely ignore the xcuserdata files - but do NOT ignore the files next to them # xcuserdata #### # XCode 4 workspaces - more detailed # # Workspaces are important! They are a core feature of Xcode - don't exclude them :) # # Workspace layout is quite spammy. For reference: # # (root)/ # (project-name).xcodeproj/ # project.pbxproj # project.xcworkspace/ # contents.xcworkspacedata # xcuserdata/ # (your name)/xcuserdatad/ # xcuserdata/ # (your name)/xcuserdatad/ # # # # Xcode 4 workspaces - SHARED # # This is UNDOCUMENTED (google: "developer.apple.com xcshareddata" - 0 results # But if you're going to kill personal workspaces, at least keep the shared ones... # # !xcshareddata #### # XCode 4 build-schemes # # PRIVATE ones are stored inside xcuserdata !xcschemes #### # Xcode 4 - Deprecated classes # # Allegedly, if you manually "deprecate" your classes, they get moved here. # # We're using source-control, so this is a "feature" that we do not want! *.moved-aside Documentation/restructured/locale/*/LC_MESSAGES/*.mo Documentation/restructured/_build/ ================================================ FILE: COPYING ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: Documentation/css/docbook-xsl.css ================================================ /* CSS stylesheet for XHTML produced by DocBook XSL stylesheets. */ body { font-family: Georgia,serif; } code, pre { font-family: "Courier New", Courier, monospace; } span.strong { font-weight: bold; } body blockquote { margin-top: .75em; line-height: 1.5; margin-bottom: .75em; } html body { margin: 1em 5% 1em 5%; line-height: 1.2; } body div { margin: 0; } h1, h2, h3, h4, h5, h6 { color: #527bbd; font-family: Arial,Helvetica,sans-serif; } div.toc p:first-child, div.list-of-figures p:first-child, div.list-of-tables p:first-child, div.list-of-examples p:first-child, div.example p.title, div.sidebar p.title { font-weight: bold; color: #527bbd; font-family: Arial,Helvetica,sans-serif; margin-bottom: 0.2em; } body h1 { margin: .0em 0 0 -4%; line-height: 1.3; border-bottom: 2px solid silver; } body h2 { margin: 0.5em 0 0 -4%; line-height: 1.3; border-bottom: 2px solid silver; } body h3 { margin: .8em 0 0 -3%; line-height: 1.3; } body h4 { margin: .8em 0 0 -3%; line-height: 1.3; } body h5 { margin: .8em 0 0 -2%; line-height: 1.3; } body h6 { margin: .8em 0 0 -1%; line-height: 1.3; } body hr { border: none; /* Broken on IE6 */ } div.footnotes hr { border: 1px solid silver; } div.navheader th, div.navheader td, div.navfooter td { font-family: Arial,Helvetica,sans-serif; font-size: 0.9em; font-weight: bold; color: #527bbd; } div.navheader img, div.navfooter img { border-style: none; } div.navheader a, div.navfooter a { font-weight: normal; } div.navfooter hr { border: 1px solid silver; } body td { line-height: 1.2 } body th { line-height: 1.2; } ol { line-height: 1.2; } ul, body dir, body menu { line-height: 1.2; } html { margin: 0; padding: 0; } body h1, body h2, body h3, body h4, body h5, body h6 { margin-left: 0 } body pre { margin: 0.5em 10% 0.5em 1em; line-height: 1.0; color: navy; } tt.literal, code.literal { color: navy; } .programlisting, .screen { border: 1px solid silver; background: #f4f4f4; margin: 0.5em 10% 0.5em 0; padding: 0.5em 1em; } div.sidebar { background: #ffffee; margin: 1.0em 10% 0.5em 0; padding: 0.5em 1em; border: 1px solid silver; } div.sidebar * { padding: 0; } div.sidebar div { margin: 0; } div.sidebar p.title { margin-top: 0.5em; margin-bottom: 0.2em; } div.bibliomixed { margin: 0.5em 5% 0.5em 1em; } div.glossary dt { font-weight: bold; } div.glossary dd p { margin-top: 0.2em; } dl { margin: .8em 0; line-height: 1.2; } dt { margin-top: 0.5em; } dt span.term { font-style: normal; color: navy; } div.variablelist dd p { margin-top: 0; } div.itemizedlist li, div.orderedlist li { margin-left: -0.8em; margin-top: 0.5em; } ul, ol { list-style-position: outside; } div.sidebar ul, div.sidebar ol { margin-left: 2.8em; } div.itemizedlist p.title, div.orderedlist p.title, div.variablelist p.title { margin-bottom: -0.8em; } div.revhistory table { border-collapse: collapse; border: none; } div.revhistory th { border: none; color: #527bbd; font-family: Arial,Helvetica,sans-serif; } div.revhistory td { border: 1px solid silver; } /* Keep TOC and index lines close together. */ div.toc dl, div.toc dt, div.list-of-figures dl, div.list-of-figures dt, div.list-of-tables dl, div.list-of-tables dt, div.indexdiv dl, div.indexdiv dt { line-height: normal; margin-top: 0; margin-bottom: 0; } /* Table styling does not work because of overriding attributes in generated HTML. */ div.table table, div.informaltable table { margin-left: 0; margin-right: 5%; margin-bottom: 0.8em; } div.informaltable table { margin-top: 0.4em } div.table thead, div.table tfoot, div.table tbody, div.informaltable thead, div.informaltable tfoot, div.informaltable tbody { /* No effect in IE6. */ border-top: 3px solid #527bbd; border-bottom: 3px solid #527bbd; } div.table thead, div.table tfoot, div.informaltable thead, div.informaltable tfoot { font-weight: bold; } div.mediaobject img { margin-bottom: 0.8em; } div.figure p.title, div.table p.title { margin-top: 1em; margin-bottom: 0.4em; } div.calloutlist p { margin-top: 0em; margin-bottom: 0.4em; } a img { border-style: none; } @media print { div.navheader, div.navfooter { display: none; } } span.aqua { color: aqua; } span.black { color: black; } span.blue { color: blue; } span.fuchsia { color: fuchsia; } span.gray { color: gray; } span.green { color: green; } span.lime { color: lime; } span.maroon { color: maroon; } span.navy { color: navy; } span.olive { color: olive; } span.purple { color: purple; } span.red { color: red; } span.silver { color: silver; } span.teal { color: teal; } span.white { color: white; } span.yellow { color: yellow; } span.aqua-background { background: aqua; } span.black-background { background: black; } span.blue-background { background: blue; } span.fuchsia-background { background: fuchsia; } span.gray-background { background: gray; } span.green-background { background: green; } span.lime-background { background: lime; } span.maroon-background { background: maroon; } span.navy-background { background: navy; } span.olive-background { background: olive; } span.purple-background { background: purple; } span.red-background { background: red; } span.silver-background { background: silver; } span.teal-background { background: teal; } span.white-background { background: white; } span.yellow-background { background: yellow; } span.big { font-size: 2em; } span.small { font-size: 0.6em; } span.underline { text-decoration: underline; } span.overline { text-decoration: overline; } span.line-through { text-decoration: line-through; } ================================================ FILE: Documentation/index.asciidoc ================================================ = Tigase Messenger for iOS Tigase Team :toc: :numbered: :website: http://tigase.net :Date: 2017-04-10 Welcome to Tigase Messenger for iOS :leveloffset: 1 include::text/welcome.asciidoc[] include::text/interface.asciidoc[] include::text/advanced.asciidoc[] ================================================ FILE: Documentation/restructured/.readthedocs.yaml ================================================ # Required version: 2 # Set the version of Python and other tools you might need build: os: ubuntu-22.04 tools: python: "3.11" # Build documentation in the docs/ directory with Sphinx sphinx: configuration: conf.py ================================================ FILE: Documentation/restructured/Advanced_Options.rst ================================================ Advanced Options ================= This section contains information about advanced settings and options that are available to the application, but may not be typically considered for users. - | activation of end-to-end-encryption - | requesting delivery receipts - | activation of auto-authorization of contacts - | optimization of settings for file exchange (files and pictures) - | activation of group chat synchronization Chats ------- First, you visit the settings menu by selecting Chats. Then you need to tap the upper left. In this submenu you can activate the end-to-end-encryption (called OMEMO). Afterwards, you go back to the settings menu by clicking Settings. |images/setting01| |images/setting02| |images/setting03| Contacts --------- In the settings menu you have to select Contacts. You activate the switch Auto-authorize contacts and you could return to the settings menu by clicking Settings. |images/setting04| |images/setting05| Media ------ In the settings menu you have to select Media.In this submenu you can optimize the settings for file exchange. You should select **File sharing via HTTP** and set the **File download limit** to 4 MB. Now you can send files and upload them to your XMPP server (instead of sending them directly to your communication partner). Files which you receive will be downloaded automatically if their size is below 4 MB. |images/setting07| |images/setting06| Experimental ------------ In the settings menu you have to select select Experimental. Here you can activate the Groupchat bookmark sync to be able to see your group chats on multiple devices. Additionally, I recommend to deactive the usage of public STUN servers. Most todays XMPP servers already provide a STUN-service. |images/setting08| After this step you can go back to the settings menu and close it. The optimization of settings is done. .. |images/setting01| image:: images/setting01.png .. |images/setting02| image:: images/setting02.png .. |images/setting03| image:: images/setting03.png .. |images/setting04| image:: images/setting04.png .. |images/setting05| image:: images/setting05.png .. |images/setting06| image:: images/setting06.png .. |images/setting07| image:: images/setting07.png .. |images/setting08| image:: images/setting08.png ================================================ FILE: Documentation/restructured/Makefile ================================================ # Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS = SPHINXBUILD = sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) ================================================ FILE: Documentation/restructured/Tigase_Messenger_iOS.rst ================================================ Tigase Messenger for iOS Interface ====================================== The menu interface for Tigase Messenger for iOS is broken up into three main panels; Chats, Contacts and Bookmarks. Contacts --------- The contacts panel serves as your Roster, displaying all the contacts you have on your roster, and displaying statuses along with their names. Tigase Messenger for iOS supports vCard-Temp Avatars and will retrieve them if they are uploaded by a user. Contacts with green icons are available or free to chat status. Contacts with yellow icons are away or extended away. Contacts with red icons are in do not disturb status. Contacts with gray icons are offline or unavailable. Note that contacts will remain gray if you decide not to allow presence notifications in the settings. You may delete or edit contacts by tapping a contact and tapping Delete. You also have the ability to edit a contact, explained in the next section. Deleting the contact will remove them from your roster, and remove any presence sharing permissions from the contact. Adding a contact ^^^^^^^^^^^^^^^^^ To add a contact, you have to click on Contacts and select the +-sign at the top of the screen. Select the account friends list you wish the new contact to be added too. Then type in the JID of the user, do not use resources, just bare JID. You may enter a friendly nickname for the contact to be added to your friend list.In this tutorial *my friend* is selected as the name for the contact. When adding users, you have two options to select: |images/siskin03| |images/join01| |images/join02| - Disclose my online status - This will allow sending of presence status and changes to this user on your roster. You may disable this to reduce network usage, however you will not be able to obtain status information. - Ask for presence updates - Turning this on will enable the applications to send presence changes to this person on the roster. You may disable this to reduce network usage, however they will not receive notifications if you turn off the phone .. Note:: These options are on by default and enable Tigase siskin IM for iOS to behave like a traditional client. Editing a contact ^^^^^^^^^^^^^^^^^^^^^ When editing a contact, you may chose to change the account that has friended the user, XMPP name, edit a roster name (which will be shown on your roster). Here, you may also decide to selectively approve or deny subscription requests to and from the user. If you do not send presence updates, they will not know whether you are online, busy, or away. If you elect not to receive presence updates, you will not receive information if they are online, busy or away. Tap the contact you want to edit, click "edit", after it is done, click "save" |images/editcontacts01| |images/editcontacts02| Settings --------- click "Chats" on the bottom of mian panel and click the upper left, below are settings for the operation and behavior of the application. Automatic ^^^^^^^^^^ To save data usage, your account status will be managed automatically using the following rules by default +-----------+--------------------------------------------------------------------------------------------------------------------------------+ | Status | Behavior | +-----------+--------------------------------------------------------------------------------------------------------------------------------+ | Online | Application has focus on the device. | +-----------+--------------------------------------------------------------------------------------------------------------------------------+ | Away / XA | Application is running in the background. | +-----------+--------------------------------------------------------------------------------------------------------------------------------+ | Offline | Application is killed or disconnected. If the device is turned off for a period of time, this will also set status to offline. | +-----------+--------------------------------------------------------------------------------------------------------------------------------+ However, you may override this logic by tapping Automatic and selecting a status manually. |images/status| Apperance ^^^^^^^^^^ - | auto, light and dark | adjust background brightness Chats ^^^^^^^ - | Lines of preview: | Sets the lines of preview text to keep within the chat window without using internal or message archive. - | Send messages on return: | If you are offline or away from connection, messages may be resent when you are back online or back in connection if this option is checked. - | Chat markers & reeipts: | Whether or not the message has been read by the receipts. Contacts ^^^^^^^^^ - | Contacts in groups: | Allows contacts to be displayed in groups as defined by the roster. Disabling this will show contacts in a flat organization. - | "Hidden" group: | Whether or not to display contacts that are added to the "hidden" group. - | Auto-authorize contacts: | Selecting this will automatically request subscription to users added to contacts. Notifications ^^^^^^^^^^^^^ - | Notifications from unknown | whether or not notifications from unknown sources will be sent to the native notification section of the device. - | Push notifications | whether or not notificaitons of new messages or calls will be received This section has one option: Whether to accept notifications from unknown. Media ^^^^^^^^^^^^^ - | File sharing via HTTP: | This setting turns on the use of HTTP file sharing using the application. The server you are connected to must support this component to enable this option. - | Simplified link to HTTP file: | This creates a simplified link to the file after uploading rather than directly sending the file. This may be useful for intermittent communications. - | File download limit: | Sets the maximum size of files being sent to the user which may be automatically donwload. - | Clear download cache: | User can choose clears the devices cache of all downloaded and saved files retrieved from HTTP upload component or older than 7 days. - | Clear link previews cache: | User can choose clears the devices cache of all previews or older than 7 days. Vcard ^^^^^^ You can set and change vCard data for your account. Tap the account you wish to edit and you will be presented with a number of fields that may be filled out. Click "change avatar" at the top where you may upload a photo as your avatar. .. |images/siskin03| image:: images/siskin03.png .. |images/join01| image:: images/join01.png .. |images/join02| image:: images/join02.png .. |images/editcontacts01| image:: images/editcontacts01.png .. |images/editcontacts02| image:: images/editcontacts02.png .. |images/status| image:: images/status.png ================================================ FILE: Documentation/restructured/Welcome.rst ================================================ Welcome ======== Welcome to the documentation for Siskin IM for iOS. Siskin IM has some nice feature: encrypted chats and group chats sending and receiving files/pictures audio- and videocalls recording and sending voice messages Sending your geolocation Minimum Requirements -------------------------- **iPhone** Requires iOS 13.0 or later. **iPad** Requires iPadOS 13.0 or later. **iPod touch** Requires iOS 13.0 or later. **Mac** Requires macOS 11.0 or later and a Mac with Apple M1 chip or later. Installation ------------- Siskin IM is a good choice if you want to use an XMPP account on your iPhone or iPad. You can get Siskin IM from the App Store (`external `__ ). Please keep in mind that Siskin IM is available in English only. Account Setup ---------------------------- After downloading Siskin IM from the App Store you can start it by clicking the Siskin IM icon. At first Siskin IM asks if it is allowed to send notifications. You should allow Siskin IM to do so. Your options now are to creat new XMPP account, or to use an existing XMPP account(if you do not already have one). Registering for a New Account ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ You have the choice between a lot of different XMPP providers. Your XMPP address will be the username you choose followed by the @-sign and the domain of the chosen provider. Some examples for XMPP providers are: magicbroccoli.de: Registration (`external `__ ). wiuwiu.de: Registration (`external link `__ ). You can also choose a provider by looking at this list: (`external `__ ). If you do not know any XMPP server domain names, then you could select one of trusted servers from the list of sisin IM provided. |images/register01| After you select trusted servers, Fill out the fields for username, password, and E-mail. You do not need to add the domain to your username, it will be added for you so your JID will look like yourusername@domain.com |images/register02| An E-mail is required in case a server administrator needs to get in contact with you, or you lose your password and might need recovery. Once you tap Register, the application will connect and register your account with the server. And you will receive the email confirmation with the link to confirm the new XMPP account. Use an Existing Account ^^^^^^^^^^^^^^^^^^^^^^^^ Now you select Sign in to an existing XMPP account since you already registered an address in the previous section. Afterwards, you have to enter your XMPP address, your password and finish these steps by clicking Save. Please keep in mind that in this tutorial the XMPP address userfortest@tigase.im is used as an example account. |images/siskin01| |images/siskin02| You will see a notification, which asks if you want to allow the Siskin server to send you Push Notifications. You should **enable** this setting to get notifications (even if the app is in the background). Siskin will now show you your XMPP address and that the setting Message Synchronization is activated. You can simply click on Done. Your XMPP address is now configured to be used in Siskin IM. Final Steps ------------ Once your account is verified, the application will log you in as online and display the chat screen. .. |images/register01| image:: images/register01.PNG .. |images/register02| image:: images/register02.PNG .. |images/siskin01| image:: images/siskin01.jpg .. |images/siskin02| image:: images/siskin02.png ================================================ FILE: Documentation/restructured/conf.py ================================================ # Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # #import os #import sys # sys.path.insert(0, os.path.abspath('.')) # -- Project information ----------------------------------------------------- project = 'TigaseDoc' copyright = '2004-2022, Tigase, Inc' author = 'Tigase, Inc.' # The full version, including alpha/beta/rc tags release = '0.1' # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # import sphinx_rtd_theme html_theme = 'sphinx_rtd_theme' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # html_static_path = ['_static'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The master toctree document. master_doc = 'index' html_theme_options = {'collapse_navigation': True, 'sticky_navigation': True, 'navigation_depth': 0, 'includehidden': True, 'titles_only': False, 'display_version': True, } gettext_compact = "siskin_im_translation" language = "zh_CN" locale_dirs = ["locale/"] gettext_compact = "siskin_im_translation" language = "pl" locale_dirs = ["locale/"] gettext_allow_fuzzy_translations = True ================================================ FILE: Documentation/restructured/index.rst ================================================ ========================================== Tigase Messenger for iOS - Version 1.0 ========================================== .. toctree:: :titlesonly: :numbered: 3 Welcome Tigase_Messenger_iOS Advanced_Options ================================================ FILE: Documentation/restructured/locale/pl/LC_MESSAGES/siskin_im_translation.po ================================================ # SOME DESCRIPTIVE TITLE. # Copyright (C) 2004-2022, Tigase, Inc # This file is distributed under the same license as the TigaseDoc package. # FIRST AUTHOR , 2023. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: TigaseDoc \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-01-17 00:18-0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.11.0\n" #: ../../Advanced_Options.rst:2 msgid "Advanced Options" msgstr "" #: ../../Advanced_Options.rst:4 msgid "" "This section contains information about advanced settings and options " "that are available to the application, but may not be typically " "considered for users." msgstr "" #: ../../Advanced_Options.rst msgid "activation of end-to-end-encryption" msgstr "" #: ../../Advanced_Options.rst msgid "requesting delivery receipts" msgstr "" #: ../../Advanced_Options.rst msgid "activation of auto-authorization of contacts" msgstr "" #: ../../Advanced_Options.rst msgid "optimization of settings for file exchange (files and pictures)" msgstr "" #: ../../Advanced_Options.rst msgid "activation of group chat synchronization" msgstr "" #: ../../Advanced_Options.rst:13 ../../Tigase_Messenger_iOS.rst:83 msgid "Chats" msgstr "" #: ../../Advanced_Options.rst:15 msgid "" "First, you visit the settings menu by selecting Chats. Then you need to " "tap the upper left. In this submenu you can activate the end-to-end-" "encryption (called OMEMO). Afterwards, you go back to the settings menu " "by clicking Settings." msgstr "" #: ../../Advanced_Options.rst:17 msgid "|images/setting01| |images/setting02| |images/setting03|" msgstr "" #: ../../Advanced_Options.rst:42 msgid "images/setting01" msgstr "" #: ../../Advanced_Options.rst:43 msgid "images/setting02" msgstr "" #: ../../Advanced_Options.rst:44 msgid "images/setting03" msgstr "" #: ../../Advanced_Options.rst:20 ../../Tigase_Messenger_iOS.rst:7 #: ../../Tigase_Messenger_iOS.rst:95 msgid "Contacts" msgstr "" #: ../../Advanced_Options.rst:22 msgid "" "In the settings menu you have to select Contacts. You activate the switch" " Auto-authorize contacts and you could return to the settings menu by " "clicking Settings." msgstr "" #: ../../Advanced_Options.rst:24 msgid "|images/setting04| |images/setting05|" msgstr "" #: ../../Advanced_Options.rst:45 msgid "images/setting04" msgstr "" #: ../../Advanced_Options.rst:46 msgid "images/setting05" msgstr "" #: ../../Advanced_Options.rst:27 ../../Tigase_Messenger_iOS.rst:119 msgid "Media" msgstr "" #: ../../Advanced_Options.rst:29 msgid "" "In the settings menu you have to select Media.In this submenu you can " "optimize the settings for file exchange. You should select **File sharing" " via HTTP** and set the **File download limit** to 4 MB. Now you can send" " files and upload them to your XMPP server (instead of sending them " "directly to your communication partner). Files which you receive will be " "downloaded automatically if their size is below 4 MB." msgstr "" #: ../../Advanced_Options.rst:31 msgid "|images/setting07| |images/setting06|" msgstr "" #: ../../Advanced_Options.rst:48 msgid "images/setting07" msgstr "" #: ../../Advanced_Options.rst:47 msgid "images/setting06" msgstr "" #: ../../Advanced_Options.rst:34 msgid "Experimental" msgstr "" #: ../../Advanced_Options.rst:36 msgid "" "In the settings menu you have to select select Experimental. Here you can" " activate the Groupchat bookmark sync to be able to see your group chats " "on multiple devices. Additionally, I recommend to deactive the usage of " "public STUN servers. Most todays XMPP servers already provide a STUN-" "service." msgstr "" #: ../../Advanced_Options.rst:38 msgid "|images/setting08|" msgstr "" #: ../../Advanced_Options.rst:49 msgid "images/setting08" msgstr "" #: ../../Advanced_Options.rst:40 msgid "" "After this step you can go back to the settings menu and close it. The " "optimization of settings is done." msgstr "" #: ../../Tigase_Messenger_iOS.rst:2 msgid "Tigase Messenger for iOS Interface" msgstr "" #: ../../Tigase_Messenger_iOS.rst:4 msgid "" "The menu interface for Tigase Messenger for iOS is broken up into three " "main panels; Chats, Contacts and Bookmarks." msgstr "" #: ../../Tigase_Messenger_iOS.rst:9 msgid "" "The contacts panel serves as your Roster, displaying all the contacts you" " have on your roster, and displaying statuses along with their names. " "Tigase Messenger for iOS supports vCard-Temp Avatars and will retrieve " "them if they are uploaded by a user." msgstr "" #: ../../Tigase_Messenger_iOS.rst:11 msgid "" "Contacts with green icons are available or free to chat status. Contacts " "with yellow icons are away or extended away. Contacts with red icons are " "in do not disturb status. Contacts with gray icons are offline or " "unavailable." msgstr "" #: ../../Tigase_Messenger_iOS.rst:16 msgid "" "Note that contacts will remain gray if you decide not to allow presence " "notifications in the settings." msgstr "" #: ../../Tigase_Messenger_iOS.rst:18 msgid "" "You may delete or edit contacts by tapping a contact and tapping Delete. " "You also have the ability to edit a contact, explained in the next " "section. Deleting the contact will remove them from your roster, and " "remove any presence sharing permissions from the contact." msgstr "" #: ../../Tigase_Messenger_iOS.rst:21 msgid "Adding a contact" msgstr "" #: ../../Tigase_Messenger_iOS.rst:24 msgid "" "To add a contact, you have to click on Contacts and select the +-sign at " "the top of the screen. Select the account friends list you wish the new " "contact to be added too. Then type in the JID of the user, do not use " "resources, just bare JID. You may enter a friendly nickname for the " "contact to be added to your friend list.In this tutorial *my friend* is " "selected as the name for the contact. When adding users, you have two " "options to select:" msgstr "" #: ../../Tigase_Messenger_iOS.rst:26 msgid "|images/siskin03| |images/join01|" msgstr "" #: ../../Tigase_Messenger_iOS.rst:141 msgid "images/siskin03" msgstr "" #: ../../Tigase_Messenger_iOS.rst:142 msgid "images/join01" msgstr "" #: ../../Tigase_Messenger_iOS.rst:28 msgid "|images/join02|" msgstr "" #: ../../Tigase_Messenger_iOS.rst:143 msgid "images/join02" msgstr "" #: ../../Tigase_Messenger_iOS.rst:31 msgid "" "Disclose my online status - This will allow sending of presence status " "and changes to this user on your roster. You may disable this to reduce " "network usage, however you will not be able to obtain status information." msgstr "" #: ../../Tigase_Messenger_iOS.rst:33 msgid "" "Ask for presence updates - Turning this on will enable the applications " "to send presence changes to this person on the roster. You may disable " "this to reduce network usage, however they will not receive notifications" " if you turn off the phone" msgstr "" #: ../../Tigase_Messenger_iOS.rst:37 msgid "" "These options are on by default and enable Tigase siskin IM for iOS to " "behave like a traditional client." msgstr "" #: ../../Tigase_Messenger_iOS.rst:40 msgid "Editing a contact" msgstr "" #: ../../Tigase_Messenger_iOS.rst:42 msgid "" "When editing a contact, you may chose to change the account that has " "friended the user, XMPP name, edit a roster name (which will be shown on " "your roster). Here, you may also decide to selectively approve or deny " "subscription requests to and from the user. If you do not send presence " "updates, they will not know whether you are online, busy, or away. If you" " elect not to receive presence updates, you will not receive information " "if they are online, busy or away." msgstr "" #: ../../Tigase_Messenger_iOS.rst:44 msgid "" "Tap the contact you want to edit, click \"edit\", after it is done, click" " \"save\"" msgstr "" #: ../../Tigase_Messenger_iOS.rst:46 msgid "|images/editcontacts01| |images/editcontacts02|" msgstr "" #: ../../Tigase_Messenger_iOS.rst:144 msgid "images/editcontacts01" msgstr "" #: ../../Tigase_Messenger_iOS.rst:145 msgid "images/editcontacts02" msgstr "" #: ../../Tigase_Messenger_iOS.rst:50 msgid "Settings" msgstr "" #: ../../Tigase_Messenger_iOS.rst:53 msgid "" "click \"Chats\" on the bottom of mian panel and click the upper left, " "below are settings for the operation and behavior of the application." msgstr "" #: ../../Tigase_Messenger_iOS.rst:56 msgid "Automatic" msgstr "" #: ../../Tigase_Messenger_iOS.rst:58 msgid "" "To save data usage, your account status will be managed automatically " "using the following rules by default" msgstr "" #: ../../Tigase_Messenger_iOS.rst:61 msgid "Status" msgstr "" #: ../../Tigase_Messenger_iOS.rst:61 msgid "Behavior" msgstr "" #: ../../Tigase_Messenger_iOS.rst:63 msgid "Online" msgstr "" #: ../../Tigase_Messenger_iOS.rst:63 msgid "Application has focus on the device." msgstr "" #: ../../Tigase_Messenger_iOS.rst:65 msgid "Away / XA" msgstr "" #: ../../Tigase_Messenger_iOS.rst:65 msgid "Application is running in the background." msgstr "" #: ../../Tigase_Messenger_iOS.rst:67 msgid "Offline" msgstr "" #: ../../Tigase_Messenger_iOS.rst:67 msgid "" "Application is killed or disconnected. If the device is turned off for a " "period of time, this will also set status to offline." msgstr "" #: ../../Tigase_Messenger_iOS.rst:70 msgid "" "However, you may override this logic by tapping Automatic and selecting a" " status manually." msgstr "" #: ../../Tigase_Messenger_iOS.rst:72 msgid "|images/status|" msgstr "" #: ../../Tigase_Messenger_iOS.rst:146 msgid "images/status" msgstr "" #: ../../Tigase_Messenger_iOS.rst:76 msgid "Apperance" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "auto, light and dark" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "adjust background brightness" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "Lines of preview:" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "" "Sets the lines of preview text to keep within the chat window without " "using internal or message archive." msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "Send messages on return:" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "" "If you are offline or away from connection, messages may be resent when " "you are back online or back in connection if this option is checked." msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "Chat markers & reeipts:" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "Whether or not the message has been read by the receipts." msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "Contacts in groups:" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "" "Allows contacts to be displayed in groups as defined by the roster. " "Disabling this will show contacts in a flat organization." msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "\"Hidden\" group:" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "Whether or not to display contacts that are added to the \"hidden\" group." msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "Auto-authorize contacts:" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "" "Selecting this will automatically request subscription to users added to " "contacts." msgstr "" #: ../../Tigase_Messenger_iOS.rst:107 msgid "Notifications" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "Notifications from unknown" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "" "whether or not notifications from unknown sources will be sent to the " "native notification section of the device." msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "Push notifications" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "whether or not notificaitons of new messages or calls will be received" msgstr "" #: ../../Tigase_Messenger_iOS.rst:116 msgid "This section has one option: Whether to accept notifications from unknown." msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "File sharing via HTTP:" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "" "This setting turns on the use of HTTP file sharing using the application." " The server you are connected to must support this component to enable " "this option." msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "Simplified link to HTTP file:" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "" "This creates a simplified link to the file after uploading rather than " "directly sending the file. This may be useful for intermittent " "communications." msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "File download limit:" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "" "Sets the maximum size of files being sent to the user which may be " "automatically donwload." msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "Clear download cache:" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "" "User can choose clears the devices cache of all downloaded and saved " "files retrieved from HTTP upload component or older than 7 days." msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "Clear link previews cache:" msgstr "" #: ../../Tigase_Messenger_iOS.rst msgid "" "User can choose clears the devices cache of all previews or older than 7 " "days." msgstr "" #: ../../Tigase_Messenger_iOS.rst:137 msgid "Vcard" msgstr "" #: ../../Tigase_Messenger_iOS.rst:139 msgid "" "You can set and change vCard data for your account. Tap the account you " "wish to edit and you will be presented with a number of fields that may " "be filled out. Click \"change avatar\" at the top where you may upload a " "photo as your avatar." msgstr "" #: ../../Welcome.rst:2 msgid "Welcome" msgstr "" #: ../../Welcome.rst:4 msgid "Welcome to the documentation for Siskin IM for iOS." msgstr "" #: ../../Welcome.rst:6 msgid "Siskin IM has some nice feature:" msgstr "" #: ../../Welcome.rst:8 msgid "" "encrypted chats and group chats sending and receiving files/pictures " "audio- and videocalls recording and sending voice messages Sending your " "geolocation" msgstr "" #: ../../Welcome.rst:15 msgid "Minimum Requirements" msgstr "" #: ../../Welcome.rst:17 msgid "**iPhone** Requires iOS 13.0 or later." msgstr "" #: ../../Welcome.rst:20 msgid "**iPad** Requires iPadOS 13.0 or later." msgstr "" #: ../../Welcome.rst:23 msgid "**iPod touch** Requires iOS 13.0 or later." msgstr "" #: ../../Welcome.rst:26 msgid "" "**Mac** Requires macOS 11.0 or later and a Mac with Apple M1 chip or " "later." msgstr "" #: ../../Welcome.rst:31 msgid "Installation" msgstr "" #: ../../Welcome.rst:33 msgid "" "Siskin IM is a good choice if you want to use an XMPP account on your " "iPhone or iPad. You can get Siskin IM from the App Store (`external " "`__ ). Please keep " "in mind that Siskin IM is available in English only." msgstr "" #: ../../Welcome.rst:37 msgid "Account Setup" msgstr "" #: ../../Welcome.rst:39 msgid "" "After downloading Siskin IM from the App Store you can start it by " "clicking the Siskin IM icon. At first Siskin IM asks if it is allowed to " "send notifications. You should allow Siskin IM to do so." msgstr "" #: ../../Welcome.rst:41 msgid "" "Your options now are to creat new XMPP account, or to use an existing " "XMPP account(if you do not already have one)." msgstr "" #: ../../Welcome.rst:44 msgid "Registering for a New Account" msgstr "" #: ../../Welcome.rst:46 msgid "" "You have the choice between a lot of different XMPP providers. Your XMPP " "address will be the username you choose followed by the @-sign and the " "domain of the chosen provider." msgstr "" #: ../../Welcome.rst:48 msgid "Some examples for XMPP providers are:" msgstr "" #: ../../Welcome.rst:50 msgid "" "magicbroccoli.de: Registration (`external " "`__ ). wiuwiu.de: Registration " "(`external link `__ ). You can also choose a provider" " by looking at this list: (`external `__ )." msgstr "" #: ../../Welcome.rst:54 msgid "" "If you do not know any XMPP server domain names, then you could select " "one of trusted servers from the list of sisin IM provided." msgstr "" #: ../../Welcome.rst:56 msgid "|images/register01|" msgstr "" #: ../../Welcome.rst:85 msgid "images/register01" msgstr "" #: ../../Welcome.rst:58 msgid "" "After you select trusted servers, Fill out the fields for username, " "password, and E-mail. You do not need to add the domain to your username," " it will be added for you so your JID will look like " "yourusername@domain.com" msgstr "" #: ../../Welcome.rst:60 msgid "|images/register02|" msgstr "" #: ../../Welcome.rst:86 msgid "images/register02" msgstr "" #: ../../Welcome.rst:62 msgid "" "An E-mail is required in case a server administrator needs to get in " "contact with you, or you lose your password and might need recovery." msgstr "" #: ../../Welcome.rst:64 msgid "" "Once you tap Register, the application will connect and register your " "account with the server. And you will receive the email confirmation with" " the link to confirm the new XMPP account." msgstr "" #: ../../Welcome.rst:67 msgid "Use an Existing Account" msgstr "" #: ../../Welcome.rst:69 msgid "" "Now you select Sign in to an existing XMPP account since you already " "registered an address in the previous section. Afterwards, you have to " "enter your XMPP address, your password and finish these steps by clicking" " Save. Please keep in mind that in this tutorial the XMPP address " "userfortest@tigase.im is used as an example account." msgstr "" #: ../../Welcome.rst:71 msgid "|images/siskin01|" msgstr "" #: ../../Welcome.rst:87 msgid "images/siskin01" msgstr "" #: ../../Welcome.rst:74 msgid "|images/siskin02|" msgstr "" #: ../../Welcome.rst:88 msgid "images/siskin02" msgstr "" #: ../../Welcome.rst:76 msgid "" "You will see a notification, which asks if you want to allow the Siskin " "server to send you Push Notifications. You should **enable** this setting" " to get notifications (even if the app is in the background). Siskin will" " now show you your XMPP address and that the setting Message " "Synchronization is activated. You can simply click on Done." msgstr "" #: ../../Welcome.rst:78 msgid "Your XMPP address is now configured to be used in Siskin IM." msgstr "" #: ../../Welcome.rst:82 msgid "Final Steps" msgstr "" #: ../../Welcome.rst:83 msgid "" "Once your account is verified, the application will log you in as online " "and display the chat screen." msgstr "" #: ../../index.rst:4 msgid "Tigase Messenger for iOS - Version 1.0" msgstr "" #~ msgid "" #~ "There is a bug in Siskin 7.0. " #~ "Siskin ignores the default encryption " #~ "setting and sends messages unencrypted " #~ "instead. You need to activate the " #~ "encryption in each chat manually. This" #~ " bug will hopefully be fixed in " #~ "version 7.0.1.." #~ msgstr "" #~ msgid "" #~ "encrypted chats and group chats sending" #~ " and receiving files/pictures audio- and" #~ " videocalls recording and sending voice " #~ "messages (Siskin IM 7.0) Sending your" #~ " geolocation (Siskin IM 7.0)" #~ msgstr "" ================================================ FILE: Documentation/restructured/locale/zh_CN/LC_MESSAGES/siskin_im_translation.po ================================================ # SOME DESCRIPTIVE TITLE. # Copyright (C) 2004-2022, Tigase, Inc # This file is distributed under the same license as the TigaseDoc package. # FIRST AUTHOR , 2023. # msgid "" msgstr "" "Project-Id-Version: TigaseDoc\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-01-17 00:18-0800\n" "PO-Revision-Date: 2023-01-19 06:49+0000\n" "Last-Translator: Qian Luo \n" "Language-Team: Chinese (Simplified) \n" "Language: zh_CN\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" "X-Generator: Weblate 4.11.2\n" "Generated-By: Babel 2.11.0\n" #: ../../Advanced_Options.rst:2 msgid "Advanced Options" msgstr "高级选项" #: ../../Advanced_Options.rst:4 msgid "" "This section contains information about advanced settings and options " "that are available to the application, but may not be typically " "considered for users." msgstr "本部分包含有关应用程序可用的高级设置和选项的信息,但用户通常不会考虑这些设置" "和选项。" #: ../../Advanced_Options.rst msgid "activation of end-to-end-encryption" msgstr "激活端到端加密" #: ../../Advanced_Options.rst msgid "requesting delivery receipts" msgstr "要求交付凭据" #: ../../Advanced_Options.rst msgid "activation of auto-authorization of contacts" msgstr "激活联系人的自动授权" #: ../../Advanced_Options.rst msgid "optimization of settings for file exchange (files and pictures)" msgstr "优化文件交换设置(文件和图片)" #: ../../Advanced_Options.rst msgid "activation of group chat synchronization" msgstr "激活群聊同步" #: ../../Advanced_Options.rst:13 ../../Tigase_Messenger_iOS.rst:83 msgid "Chats" msgstr "聊天" #: ../../Advanced_Options.rst:15 msgid "" "First, you visit the settings menu by selecting Chats. Then you need to " "tap the upper left. In this submenu you can activate the end-to-end-" "encryption (called OMEMO). Afterwards, you go back to the settings menu " "by clicking Settings." msgstr "" "首先,您可以通过选择“聊天”来访问设置菜单。然后你需要点击左上角。在此子菜单中" ",您可以激活端到端加密(称为 " "OMEMO)。之后,您可以通过单击“设置”返回设置菜单。" #: ../../Advanced_Options.rst:17 msgid "|images/setting01| |images/setting02| |images/setting03|" msgstr "|images/setting01| |images/setting02| |images/setting03|" #: ../../Advanced_Options.rst:42 msgid "images/setting01" msgstr "images/setting01" #: ../../Advanced_Options.rst:43 msgid "images/setting02" msgstr "images/setting02" #: ../../Advanced_Options.rst:44 msgid "images/setting03" msgstr "images/setting03" #: ../../Advanced_Options.rst:20 ../../Tigase_Messenger_iOS.rst:7 #: ../../Tigase_Messenger_iOS.rst:95 msgid "Contacts" msgstr "联系人" #: ../../Advanced_Options.rst:22 msgid "" "In the settings menu you have to select Contacts. You activate the switch" " Auto-authorize contacts and you could return to the settings menu by " "clicking Settings." msgstr "在设置菜单中,您必须选择“联系人”。您激活开关自动授权联系人,您可以通过单击“设" "置”返回设置菜单。" #: ../../Advanced_Options.rst:24 msgid "|images/setting04| |images/setting05|" msgstr "|images/setting04| |images/setting05|" #: ../../Advanced_Options.rst:45 msgid "images/setting04" msgstr "images/setting04" #: ../../Advanced_Options.rst:46 msgid "images/setting05" msgstr "images/setting05" #: ../../Advanced_Options.rst:27 ../../Tigase_Messenger_iOS.rst:119 msgid "Media" msgstr "媒体" #: ../../Advanced_Options.rst:29 msgid "" "In the settings menu you have to select Media.In this submenu you can " "optimize the settings for file exchange. You should select **File sharing" " via HTTP** and set the **File download limit** to 4 MB. Now you can send" " files and upload them to your XMPP server (instead of sending them " "directly to your communication partner). Files which you receive will be " "downloaded automatically if their size is below 4 MB." msgstr "" "在设置菜单中,您必须选择“媒体”。在此子菜单中,您可以优化文件交换设置。您应该" "选择**通过 HTTP 共享文件**并将**文件下载限制**设置为 4 MB。" "现在您可以发送文件并将它们上传到您的 XMPP " "服务器(而不是直接将它们发送给您的通信伙伴)。如果文件大小低于 4 " "MB,您收到的文件将自动下载。" #: ../../Advanced_Options.rst:31 msgid "|images/setting07| |images/setting06|" msgstr "|images/setting07| |images/setting06|" #: ../../Advanced_Options.rst:48 msgid "images/setting07" msgstr "images/setting07" #: ../../Advanced_Options.rst:47 msgid "images/setting06" msgstr "images/setting06" #: ../../Advanced_Options.rst:34 msgid "Experimental" msgstr "实验" #: ../../Advanced_Options.rst:36 msgid "" "In the settings menu you have to select select Experimental. Here you can" " activate the Groupchat bookmark sync to be able to see your group chats " "on multiple devices. Additionally, I recommend to deactive the usage of " "public STUN servers. Most todays XMPP servers already provide a STUN-" "service." msgstr "" "在设置菜单中,您必须选择“实验”。您可以在此处激活群聊书签同步,以便能够在多台" "设备上查看您的群聊。此外,我建议停用公共 STUN 服务器的使用。大多数当今的 " "XMPP 服务器已经提供了 STUN 服务。" #: ../../Advanced_Options.rst:38 msgid "|images/setting08|" msgstr "|images/setting08|" #: ../../Advanced_Options.rst:49 msgid "images/setting08" msgstr "images/setting08" #: ../../Advanced_Options.rst:40 msgid "" "After this step you can go back to the settings menu and close it. The " "optimization of settings is done." msgstr "完成此步骤后,您可以返回设置菜单并将其关闭。设置优化完成。" #: ../../Tigase_Messenger_iOS.rst:2 msgid "Tigase Messenger for iOS Interface" msgstr "iOS 界面的 Tigase Messenger" #: ../../Tigase_Messenger_iOS.rst:4 msgid "" "The menu interface for Tigase Messenger for iOS is broken up into three " "main panels; Chats, Contacts and Bookmarks." msgstr "iOS版 Tigase Messenger 的菜单界面分为三个主要面板;聊天、联系人和书签。" #: ../../Tigase_Messenger_iOS.rst:9 msgid "" "The contacts panel serves as your Roster, displaying all the contacts you" " have on your roster, and displaying statuses along with their names. " "Tigase Messenger for iOS supports vCard-Temp Avatars and will retrieve " "them if they are uploaded by a user." msgstr "" "联系人面板用作您的花名册,显示您名册上的所有联系人,并显示状态及其姓名。" "适用于 iOS 的 Tigase Messenger 支持 vCard-Temp " "Avatars,如果用户上传它们,将会检索它们。" #: ../../Tigase_Messenger_iOS.rst:11 msgid "" "Contacts with green icons are available or free to chat status. Contacts " "with yellow icons are away or extended away. Contacts with red icons are " "in do not disturb status. Contacts with gray icons are offline or " "unavailable." msgstr "" "带有绿色图标的联系人表示此用户可以聊天。带有黄色图标的联系人表示已离开或长时" "间离开。带有红色图标的联系人处于请勿打扰状态。带有灰色图标的联系人处于离线状" "态或不可用。" #: ../../Tigase_Messenger_iOS.rst:16 msgid "" "Note that contacts will remain gray if you decide not to allow presence " "notifications in the settings." msgstr "请注意,如果您决定不允许在设置中显示状态通知,那么联系人将保持灰色。" #: ../../Tigase_Messenger_iOS.rst:18 msgid "" "You may delete or edit contacts by tapping a contact and tapping Delete. " "You also have the ability to edit a contact, explained in the next " "section. Deleting the contact will remove them from your roster, and " "remove any presence sharing permissions from the contact." msgstr "" "您可以通过点击联系人并点击删除来删除联系人。您还可以编辑联系人,这将在下一节" "中进行说明。删除联系人会将他们从您的花名册中移除,并移除该联系人的所有状态共" "享权限。" #: ../../Tigase_Messenger_iOS.rst:21 msgid "Adding a contact" msgstr "添加联系人" #: ../../Tigase_Messenger_iOS.rst:24 msgid "" "To add a contact, you have to click on Contacts and select the +-sign at " "the top of the screen. Select the account friends list you wish the new " "contact to be added too. Then type in the JID of the user, do not use " "resources, just bare JID. You may enter a friendly nickname for the " "contact to be added to your friend list.In this tutorial *my friend* is " "selected as the name for the contact. When adding users, you have two " "options to select:" msgstr "" "要添加联系人,您必须单击联系人并选择屏幕顶部的 +号。选择您希望添加新联系人的" "帐户好友列表。然后输入用户的JID,不使用资源,只是裸JID。您可以为要添加到您的" "朋友列表中的联系人输入一个友好的昵称。在本教程中,选择 *my friend* " "作为联系人的姓名。添加用户时,您有两个选项可供选择:" #: ../../Tigase_Messenger_iOS.rst:26 msgid "|images/siskin03| |images/join01|" msgstr "|images/siskin03| |images/join01|" #: ../../Tigase_Messenger_iOS.rst:141 msgid "images/siskin03" msgstr "images/siskin03" #: ../../Tigase_Messenger_iOS.rst:142 msgid "images/join01" msgstr "images/join01" #: ../../Tigase_Messenger_iOS.rst:28 msgid "|images/join02|" msgstr "|images/join02|" #: ../../Tigase_Messenger_iOS.rst:143 msgid "images/join02" msgstr "images/join02" #: ../../Tigase_Messenger_iOS.rst:31 msgid "" "Disclose my online status - This will allow sending of presence status " "and changes to this user on your roster. You may disable this to reduce " "network usage, however you will not be able to obtain status information." msgstr "公开我的在线状态 - 这将允许向您名册上的该用户发送在线状态和更改。您可以禁用此" "功能以减少网络使用,但是您将无法获得状态信息。" #: ../../Tigase_Messenger_iOS.rst:33 msgid "" "Ask for presence updates - Turning this on will enable the applications " "to send presence changes to this person on the roster. You may disable " "this to reduce network usage, however they will not receive notifications" " if you turn off the phone" msgstr "" "询问在线状态更新 - 打开此功能将使应用程序能够将在线状态更改发送给名册上的此人" "。您可以禁用此功能以减少网络使用,但是如果您关闭手机,他们将不会收到通知" #: ../../Tigase_Messenger_iOS.rst:37 msgid "" "These options are on by default and enable Tigase siskin IM for iOS to " "behave like a traditional client." msgstr "这些选项在默认情况下处于启用状态,并使适用于 iOS 的 Tigase siskin IM " "能够像传统客户端一样运行。" #: ../../Tigase_Messenger_iOS.rst:40 msgid "Editing a contact" msgstr "编辑联系人" #: ../../Tigase_Messenger_iOS.rst:42 msgid "" "When editing a contact, you may chose to change the account that has " "friended the user, XMPP name, edit a roster name (which will be shown on " "your roster). Here, you may also decide to selectively approve or deny " "subscription requests to and from the user. If you do not send presence " "updates, they will not know whether you are online, busy, or away. If you" " elect not to receive presence updates, you will not receive information " "if they are online, busy or away." msgstr "" "编辑联系人时,您可以选择更改已加好友的帐户、XMPP 名称、编辑名册名称(将显示在" "您的名册中)。在这里,您还可以决定有选择地批准或拒绝用户的订阅请求。如果您不" "发送状态更新,他们将不知道您是否在线、忙碌或离开。如果您选择不接收状态更新," "您将不会收到他们在线、忙碌或离开时的信息。" #: ../../Tigase_Messenger_iOS.rst:44 msgid "" "Tap the contact you want to edit, click \"edit\", after it is done, click" " \"save\"" msgstr "点击要编辑的联系人,点击\"edit\",完成后点击\"save\"" #: ../../Tigase_Messenger_iOS.rst:46 msgid "|images/editcontacts01| |images/editcontacts02|" msgstr "|images/editcontacts01| |images/editcontacts02|" #: ../../Tigase_Messenger_iOS.rst:144 msgid "images/editcontacts01" msgstr "images/editcontacts01" #: ../../Tigase_Messenger_iOS.rst:145 msgid "images/editcontacts02" msgstr "images/editcontacts02" #: ../../Tigase_Messenger_iOS.rst:50 msgid "Settings" msgstr "设置" #: ../../Tigase_Messenger_iOS.rst:53 msgid "" "click \"Chats\" on the bottom of mian panel and click the upper left, " "below are settings for the operation and behavior of the application." msgstr "单击主面板底部的 \"Chats\",然后单击左上角,下面是应用程序操作和行为的设置。" #: ../../Tigase_Messenger_iOS.rst:56 msgid "Automatic" msgstr "自动" #: ../../Tigase_Messenger_iOS.rst:58 msgid "" "To save data usage, your account status will be managed automatically " "using the following rules by default" msgstr "为了节省数据使用量,您的帐户状态将默认使用以下规则自动管理" #: ../../Tigase_Messenger_iOS.rst:61 msgid "Status" msgstr "状态" #: ../../Tigase_Messenger_iOS.rst:61 msgid "Behavior" msgstr "行为" #: ../../Tigase_Messenger_iOS.rst:63 msgid "Online" msgstr "在线" #: ../../Tigase_Messenger_iOS.rst:63 msgid "Application has focus on the device." msgstr "应用程序已将焦点放在设备上。" #: ../../Tigase_Messenger_iOS.rst:65 msgid "Away / XA" msgstr "离开/ XA" #: ../../Tigase_Messenger_iOS.rst:65 msgid "Application is running in the background." msgstr "应用程序正在后台运行。" #: ../../Tigase_Messenger_iOS.rst:67 msgid "Offline" msgstr "离线" #: ../../Tigase_Messenger_iOS.rst:67 msgid "" "Application is killed or disconnected. If the device is turned off for a " "period of time, this will also set status to offline." msgstr "应用程序被中止或断开连接。如果设备关闭一段时间,这也会将状态设置为离线。" #: ../../Tigase_Messenger_iOS.rst:70 msgid "" "However, you may override this logic by tapping Automatic and selecting a" " status manually." msgstr "但是,您可以通过点击Automatic并手动选择状态来覆盖此默认状态。" #: ../../Tigase_Messenger_iOS.rst:72 msgid "|images/status|" msgstr "|images/status|" #: ../../Tigase_Messenger_iOS.rst:146 msgid "images/status" msgstr "images/status" #: ../../Tigase_Messenger_iOS.rst:76 msgid "Apperance" msgstr "外观" #: ../../Tigase_Messenger_iOS.rst msgid "auto, light and dark" msgstr "自动,明和暗" #: ../../Tigase_Messenger_iOS.rst msgid "adjust background brightness" msgstr "调整背景亮度" #: ../../Tigase_Messenger_iOS.rst msgid "Lines of preview:" msgstr "预览行:" #: ../../Tigase_Messenger_iOS.rst msgid "" "Sets the lines of preview text to keep within the chat window without " "using internal or message archive." msgstr "将预览文本行设置为保留在聊天窗口中,而不使用内部或消息存档。" #: ../../Tigase_Messenger_iOS.rst msgid "Send messages on return:" msgstr "返回时发送消息:" #: ../../Tigase_Messenger_iOS.rst msgid "" "If you are offline or away from connection, messages may be resent when " "you are back online or back in connection if this option is checked." msgstr "如果您处于离线或断开连接状态,当选中此选项时,那么当您重新在线或重新连接时可" "能会重新发送消息。" #: ../../Tigase_Messenger_iOS.rst msgid "Chat markers & reeipts:" msgstr "聊天标记和凭证:" #: ../../Tigase_Messenger_iOS.rst msgid "Whether or not the message has been read by the receipts." msgstr "消息是否已被收信人阅读。" #: ../../Tigase_Messenger_iOS.rst msgid "Contacts in groups:" msgstr "群组联系人:" #: ../../Tigase_Messenger_iOS.rst msgid "" "Allows contacts to be displayed in groups as defined by the roster. " "Disabling this will show contacts in a flat organization." msgstr "允许按花名册定义的组显示联系人。禁用此功能将显示扁平组织中的联系人。" #: ../../Tigase_Messenger_iOS.rst msgid "\"Hidden\" group:" msgstr "\"Hidden\" 组:" #: ../../Tigase_Messenger_iOS.rst msgid "Whether or not to display contacts that are added to the \"hidden\" group." msgstr "是否显示添加到\"hidden\" 组的联系人。" #: ../../Tigase_Messenger_iOS.rst msgid "Auto-authorize contacts:" msgstr "自动授权联系人:" #: ../../Tigase_Messenger_iOS.rst msgid "" "Selecting this will automatically request subscription to users added to " "contacts." msgstr "选择此项将自动请求订阅添加到联系人的用户。" #: ../../Tigase_Messenger_iOS.rst:107 msgid "Notifications" msgstr "通知" #: ../../Tigase_Messenger_iOS.rst msgid "Notifications from unknown" msgstr "来自未知来源的通知" #: ../../Tigase_Messenger_iOS.rst msgid "" "whether or not notifications from unknown sources will be sent to the " "native notification section of the device." msgstr "来自未知来源的通知是否会发送到设备的本机通知部分。" #: ../../Tigase_Messenger_iOS.rst msgid "Push notifications" msgstr "推送通知" #: ../../Tigase_Messenger_iOS.rst msgid "whether or not notificaitons of new messages or calls will be received" msgstr "是否收到新消息或来电通知" #: ../../Tigase_Messenger_iOS.rst:116 msgid "This section has one option: Whether to accept notifications from unknown." msgstr "本节有一个选项:是否接受来自未知来源的通知。" #: ../../Tigase_Messenger_iOS.rst msgid "File sharing via HTTP:" msgstr "通过 HTTP 共享文件:" #: ../../Tigase_Messenger_iOS.rst msgid "" "This setting turns on the use of HTTP file sharing using the application." " The server you are connected to must support this component to enable " "this option." msgstr "此设置启用使用应用程序的 HTTP " "文件共享。您连接的服务器必须支持此组件才能启用此选项。" #: ../../Tigase_Messenger_iOS.rst msgid "Simplified link to HTTP file:" msgstr "HTTP 文件的简化链接:" #: ../../Tigase_Messenger_iOS.rst msgid "" "This creates a simplified link to the file after uploading rather than " "directly sending the file. This may be useful for intermittent " "communications." msgstr "这会在上传后创建指向文件的简化链接,而不是直接发送文件。这可能对间歇性通信有" "用。" #: ../../Tigase_Messenger_iOS.rst msgid "File download limit:" msgstr "文件下载限制:" #: ../../Tigase_Messenger_iOS.rst msgid "" "Sets the maximum size of files being sent to the user which may be " "automatically donwload." msgstr "设置发送给用户的可以自动下载的最大文件大小。" #: ../../Tigase_Messenger_iOS.rst msgid "Clear download cache:" msgstr "清除下载缓存:" #: ../../Tigase_Messenger_iOS.rst msgid "" "User can choose clears the devices cache of all downloaded and saved " "files retrieved from HTTP upload component or older than 7 days." msgstr "用户可以选择清除设备缓存中从 HTTP 上传组件检索到的或早于 7 " "天的所有下载和保存的文件。" #: ../../Tigase_Messenger_iOS.rst msgid "Clear link previews cache:" msgstr "清除链接预览缓存:" #: ../../Tigase_Messenger_iOS.rst msgid "" "User can choose clears the devices cache of all previews or older than 7 " "days." msgstr "用户可以选择清除所有预览或早于 7 天的设备缓存。" #: ../../Tigase_Messenger_iOS.rst:137 msgid "Vcard" msgstr "电子名片" #: ../../Tigase_Messenger_iOS.rst:139 msgid "" "You can set and change vCard data for your account. Tap the account you " "wish to edit and you will be presented with a number of fields that may " "be filled out. Click \"change avatar\" at the top where you may upload a " "photo as your avatar." msgstr "" "您可以为您的帐户设置和更改电子名片数据。点击您要编辑的帐户,您将看到许多可以" "填写的字段。点击顶部的\"change avatar\",您可以上传照片作为您的头像。" #: ../../Welcome.rst:2 msgid "Welcome" msgstr "欢迎" #: ../../Welcome.rst:4 msgid "Welcome to the documentation for Siskin IM for iOS." msgstr "欢迎使用 iOS 版 Siskin IM 文档。" #: ../../Welcome.rst:6 msgid "Siskin IM has some nice feature:" msgstr "Siskin IM 有一些不错的功能:" #: ../../Welcome.rst:8 msgid "" "encrypted chats and group chats sending and receiving files/pictures " "audio- and videocalls recording and sending voice messages Sending your " "geolocation" msgstr "加密聊天和群组聊天发送和接收文件/图片 ,音频和视频通话 " ",录制和发送语音消息并发送您的地理位置" #: ../../Welcome.rst:15 msgid "Minimum Requirements" msgstr "最低要求" #: ../../Welcome.rst:17 msgid "**iPhone** Requires iOS 13.0 or later." msgstr "**iPhone** 需要 iOS 13.0 或更高版本。" #: ../../Welcome.rst:20 msgid "**iPad** Requires iPadOS 13.0 or later." msgstr "**iPad** 需要 iPadOS 13.0 或更高版本。" #: ../../Welcome.rst:23 msgid "**iPod touch** Requires iOS 13.0 or later." msgstr "**iPod touch** 需要 iOS 13.0 或更高版本。" #: ../../Welcome.rst:26 msgid "" "**Mac** Requires macOS 11.0 or later and a Mac with Apple M1 chip or " "later." msgstr "**Mac** 需要 macOS 11.0 或更高版本以及配备 Apple M1 芯片或更高版本的 Mac。" #: ../../Welcome.rst:31 msgid "Installation" msgstr "安装" #: ../../Welcome.rst:33 msgid "" "Siskin IM is a good choice if you want to use an XMPP account on your " "iPhone or iPad. You can get Siskin IM from the App Store (`external " "`__ ). Please keep " "in mind that Siskin IM is available in English only." msgstr "" "如果您想在 iPhone 或 iPad 上使用 XMPP 帐户,Siskin IM 是一个不错的选择。" "您可以从 App Store 获取 Siskin IM(`external `__)。请记住,Siskin IM 仅提供英文版本。" #: ../../Welcome.rst:37 msgid "Account Setup" msgstr "帐户设置" #: ../../Welcome.rst:39 msgid "" "After downloading Siskin IM from the App Store you can start it by " "clicking the Siskin IM icon. At first Siskin IM asks if it is allowed to " "send notifications. You should allow Siskin IM to do so." msgstr "" "从 App Store 下载 Siskin IM 后,您可以通过单击 Siskin IM 图标启动它。起初 " "Siskin IM 询问是否允许发送通知。您应该允许 Siskin IM 这样做" #: ../../Welcome.rst:41 msgid "" "Your options now are to creat new XMPP account, or to use an existing " "XMPP account(if you do not already have one)." msgstr "您现在的选择是创建新的 XMPP 帐户(如果您还没有), 或使用现有的 XMPP 帐户。" #: ../../Welcome.rst:44 msgid "Registering for a New Account" msgstr "注册一个新帐户" #: ../../Welcome.rst:46 msgid "" "You have the choice between a lot of different XMPP providers. Your XMPP " "address will be the username you choose followed by the @-sign and the " "domain of the chosen provider." msgstr "您可以在许多不同的 XMPP 提供商之间进行选择。您的 XMPP " "地址将是您选择的用户名,后跟 @ 符号和所选提供商的域。" #: ../../Welcome.rst:48 msgid "Some examples for XMPP providers are:" msgstr "XMPP 提供商的一些示例是:" #: ../../Welcome.rst:50 msgid "" "magicbroccoli.de: Registration (`external " "`__ ). wiuwiu.de: Registration " "(`external link `__ ). You can also choose a provider" " by looking at this list: (`external `__ )." msgstr "" "magicbroccoli.de: 注册 (`external `__ " ")。wiuwiu.de: 注册 (`external link `__ " ").。您还可以通过查看此列表来选择提供商: (`external `__ )。" #: ../../Welcome.rst:54 msgid "" "If you do not know any XMPP server domain names, then you could select " "one of trusted servers from the list of sisin IM provided." msgstr "如果您不知道任何 XMPP 服务器域名,那么您可以从提供的 sisin IM " "列表中选择一个受信任的服务器。" #: ../../Welcome.rst:56 msgid "|images/register01|" msgstr "|images/register01|" #: ../../Welcome.rst:85 msgid "images/register01" msgstr "images/register01" #: ../../Welcome.rst:58 msgid "" "After you select trusted servers, Fill out the fields for username, " "password, and E-mail. You do not need to add the domain to your username," " it will be added for you so your JID will look like " "yourusername@domain.com" msgstr "" "选择受信任的服务器后,填写用户名、密码和电子邮件字段。您不需要将域添加到您的" "用户名,它将为您添加,因此您的 JID 看起来像 yourusername@domain.com" #: ../../Welcome.rst:60 msgid "|images/register02|" msgstr "|images/register02|" #: ../../Welcome.rst:86 msgid "images/register02" msgstr "images/register02" #: ../../Welcome.rst:62 msgid "" "An E-mail is required in case a server administrator needs to get in " "contact with you, or you lose your password and might need recovery." msgstr "需要一个电子邮件以防服务器管理员需要与您联系,或者您丢失密码并可能需要恢复。" #: ../../Welcome.rst:64 msgid "" "Once you tap Register, the application will connect and register your " "account with the server. And you will receive the email confirmation with" " the link to confirm the new XMPP account." msgstr "点击注册后,应用程序将连接并向服务器注册您的帐户。您将收到带有确认新 XMPP " "帐户链接的电子邮件确认。" #: ../../Welcome.rst:67 msgid "Use an Existing Account" msgstr "使用现有帐户" #: ../../Welcome.rst:69 msgid "" "Now you select Sign in to an existing XMPP account since you already " "registered an address in the previous section. Afterwards, you have to " "enter your XMPP address, your password and finish these steps by clicking" " Save. Please keep in mind that in this tutorial the XMPP address " "userfortest@tigase.im is used as an example account." msgstr "" "现在您选择登录现有的 XMPP 帐户,因为您已经在上一节中注册了一个地址。之后," "您必须输入您的 XMPP " "地址、密码并通过单击保存完成这些步骤。请记住,在本教程中,XMPP 地址 " "userfortest@tigase.im 用作示例帐户。" #: ../../Welcome.rst:71 msgid "|images/siskin01|" msgstr "|images/siskin01|" #: ../../Welcome.rst:87 msgid "images/siskin01" msgstr "images/siskin01" #: ../../Welcome.rst:74 msgid "|images/siskin02|" msgstr "|images/siskin02|" #: ../../Welcome.rst:88 msgid "images/siskin02" msgstr "images/siskin02" #: ../../Welcome.rst:76 msgid "" "You will see a notification, which asks if you want to allow the Siskin " "server to send you Push Notifications. You should **enable** this setting" " to get notifications (even if the app is in the background). Siskin will" " now show you your XMPP address and that the setting Message " "Synchronization is activated. You can simply click on Done." msgstr "" "您将看到一条通知,询问您是否要允许 Siskin 服务器向您发送推送通知。您应该**启" "用**此设置以获取通知(即使应用程序在后台)。 Siskin 现在将向您显示您的 XMPP " "地址,并且消息同步设置已激活。您只需单击完成即可。" #: ../../Welcome.rst:78 msgid "Your XMPP address is now configured to be used in Siskin IM." msgstr "您的 XMPP 地址现已配置并在 Siskin IM 中使用。" #: ../../Welcome.rst:82 msgid "Final Steps" msgstr "最后的步骤" #: ../../Welcome.rst:83 msgid "" "Once your account is verified, the application will log you in as online " "and display the chat screen." msgstr "一旦验证您的帐户后,该应用程序将使您在线登录并显示聊天屏幕。" #: ../../index.rst:4 msgid "Tigase Messenger for iOS - Version 1.0" msgstr "适用于 iOS 的 Tigase Messenger - 版本 1.0" #~ msgid "" #~ "There is a bug in Siskin 7.0. " #~ "Siskin ignores the default encryption " #~ "setting and sends messages unencrypted " #~ "instead. You need to activate the " #~ "encryption in each chat manually. This" #~ " bug will hopefully be fixed in " #~ "version 7.0.1.." #~ msgstr "" #~ msgid "" #~ "encrypted chats and group chats sending" #~ " and receiving files/pictures audio- and" #~ " videocalls recording and sending voice " #~ "messages (Siskin IM 7.0) Sending your" #~ " geolocation (Siskin IM 7.0)" #~ msgstr "" ================================================ FILE: Documentation/restructured/make.bat ================================================ @ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=source set BUILDDIR=build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd ================================================ FILE: Documentation/text/advanced.asciidoc ================================================ [[iOS_Advanced]] = Advanced Options :author: Tigase Team :toc: :numbered: :website: http://tigase.net This section contains information about advanced settings and options that are available to the application, but may not be typically considered for users. [[acctSettings]] == Account Settings For each connected account, there are sever-specific settings that are available. This may be brought up by selecting More... and then choosing the account you wish to edit. image::images\acctsetting.png[] *General* - Enabled: + Whether or not to enable this account. If it is disabled, it will be considered unavailable and offline. + TIP: Push notifications will not work if the account is disabled! - Change account settings: + This screen allows changing of the account password if needed. *Push Notifications* Tigase Messenger for iOS supports link:https://xmpp.org/extensions/xep-0357.html[XEP-0357 Push Notifications] which will receive notifications when a device may be inactive, or the application is closed by the system. Devices must be registered for push notifications and must register them VIA the Tigase XMPP Push Component, enabling push components will register the device you are using. - Enabled: + Enables Push notification support. Enabling this will register the device, and enable notifcations. + - When in Away/XA/DND state: + When enabled, push notifications will be delivered when in Away, Extended away, or Do not disturb statuses which may exist while the device is inactive. + *Message Archiving* - Enabled: + Enabling this will allow the device to use the server's message archive component. This will allow storage and retrieval of messages. + - Automatic synchronization: + If this is enabled, it will synchronize with the server upon connection, sharing and retrieving message history. + - Synchronization: + Choose the level of synchronization that the device will retrieve and send to the server. + ================================================ FILE: Documentation/text/interface.asciidoc ================================================ [[Interface]] = Tigase Messenger for iOS Interface :author: Tigase Team :toc: :numbered: :website: http://tigase.net The menu interface for Tigase Messenger for iOS is broken up into three main panels; xref:recent[Recent], xref:contacts[Contacts] and xref:more[More]. This can be brought up from any screen by swiping right from the left side of the screen, or tapping the back option on the top left. [recent] == Recent The recent menu displays recent conversations with other users, and also serves as a way to navigate between multi-user chatrooms (MUCs). Each conversation will be displayed here along with an icon indicating user or room status. image::images\recent.png[] Tapping one of these conversations will bring up the chat, whether it is MUC or one on one. This panel also serves as an archive of sorts, and previous conversations with users will be accessible in this panel. NOTE: Conversations will only be saved if they took place on this device, or if message archive is active. You may clear conversations from the archive by dragging the name or MUC conversation to the left and selecting delete. If you are removing a MUC chat, you will leave the chatroom. image:images\delchat.png[] === New/Join MUC Tapping the plus button on the top right will bring up the new/join muc panel. This interface will allow you to either join an existing or create a new MUC on your chosen server. image::images\join.png[] - Account: This is the account that will handle data for the MUC chatroom. This is available for users who have multiple accounts logged in. - Server: The server the chatroom is located on, in many cases the muc server will be muc.servername.com, but may be different. - Room: The name of the chatroom you wish to create or join. - Nickname: Your name for use inside the MUC. This will become `yournickname@muc.server.com`. MUC conversations do not leak your XMPP account, so a nickname is required. - Password: The password for the MUC room. If you are creating a new chatroom, this will serve as the chat room password. Once you are finished, tap Join and you will join, or the room will be opened for you. The recent panel will now display the chatroom, you may tap it to enter the MUC interface. When in a chatroom, you may view the occupants by tapping Occupants, and will be given a list and statuses of the room participants. image::images\occu.png[] [contacts] == Contacts The contacts panel serves as your Roster, displaying all the contacts you have on your roster, and displaying statuses along with their names. Tigase Messenger for iOS supports vCard-Temp Avatars and will retrieve them if they are uploaded by a user. image::images\roster.png[] Contacts with green icons are available or free to chat status. + Contacts with yellow icons are away or extended away. + Contacts with red icons are in do not disturb status. + Contacts with gray icons are offline or unavailable. + Note that contacts will remain gray if you decide not to allow presence notifications in the settings. You may remove or edit contacts by dragging a contact to the left and tapping Delete. You also have the ability to edit a contact, explained in the next section. Deleting the contact will remove them from your roster, and remove any presence sharing permissions from the contact. image::images\deluser.PNG[] You may also filter contacts by status by selecting All to display all users, or Available to hide users that are offline or unavailable. === Editing a contact When editing a contact, you may chose to change the account that has friended the user, XMPP name, edit a roster name (which will be shown on your roster). Here, you may also decide to selectively approve or deny subscription requests to and from the user. If you do not send presence updates, they will not know whether you are online, busy, or away. If you elect not to receive presence updates, you will not receive information if they are online, busy or away. image::\images\edituser.png[] === Adding a contact To add a contact, tap the plus button in the upper left and the add contact screen will show. image::images\adduser.png[] First, select the account friends list you wish the new contact to be added too. Then type in the JID of the user, do not use resources, just bare JID. You may enter a friendly nickname for the contact to be added to your friend list, this is optional. When adding users, you have two options to select: - Send presence updates - This will allow sending of presence status and changes to this user on your roster. You may disable this to reduce network usage, however you will not be able to obtain status information. - Receive presence updates - Turning this on will enable the applications to send presence changes to this person on the roster. You may disable this to reduce network usage, however they will not receive notifications if you turn off the phone NOTE: These options are on by default and enable Tigase Messenger for iOS to behave like a traditional client. If you do decide to receive presence updates when adding a new contact, you will be presented with this screen when they add you back: image::images\presreq.png[] By tapping yes, you will receive notifications of presence changes from your contact. This subscription will be maintained by the server, and will stay active with your friends list. NOTE: You will only receive this option if 'automatically accept presence requests' is set to yes in account settings. TIP: If somebody not on your friends list adds you, you will receive this same message. [more] == More The more panel is your program and account settings panel, from here you can change program settings and general account information. image::images\settings.png[] === Accounts This will list your current accounts, if an avatar has been defined for the account, it will show on the left side but by default the Tigase logo will be used. === vCard data You can set and change vCard data for your account. Tap the account you wish to edit and you will be presented with a number of fields that may be filled out. There is a blank space in the upper left corner where you may upload a photo as your avatar. === Badge descriptions We have included a badging system on accounts to help indicate if connections issues are present with any account setup. |=== |Icon | Meaning |No icon | If account is disabled and will not try to connect |Red icon with a cross |Account is disabled and will not try to connect due to server reporting an error (persistent error, i.e. authentication error). |Grey |Account attempts to connect but is unable to connect to server (usually it means client is unable to establish TCP connection with the server) In this state, account tries to reconnect every few seconds if the client is in the foreground. |Orange with dots |TCP connection is established but XMPP stream is not ready yet (not authorized yet, awaiting resource binding, etc). |Green |XMPP client is connected and XMPP stream is established and ready to send/receive stanzas. |=== === Delete an account If you wish to remove an account, swipe left and select Delete. You will be asked for a confirmation whether you want to remove it from the application, and if the server supports it, you may delete it from the server removing roster, presence subscriptions, and potentially saved history. image::images\delacct.png[] WARNING: Deleting your account from the server is a permanent and non-reversible action. You may also add multiple XMPP accounts from this screen. The add account screen looks identical to the one seen in the xref:existing[existing account] section. To change settings for an individual account, tap that account name. Those options are covered under xref:acctSettings[Account Settings] section. === Status Below accounts is a status setting for all connected and online accounts. To save data usage, your account status will be managed automatically using the following rules by default |=== |Status | Behavior |Online | Application has focus on the device. |Away / XA | Application is running in the background. |Offline | Application is killed or disconnected. If the device is turned off for a period of time, this will also set status to offline. |=== However, you may override this logic by tapping Automatic and selecting a status manually. image::images\setstatus.png[] ==== Show tag Underneath is a blank space where you can set your show tag Editing this text section will change the `` tags in your status. Once you press OK, your new show tag will display. [settings] === Settings Below are settings for the operation and behavior of the application. image:\images\chatsettings.png[] ==== Chats *List of Messages* - Lines of preview: + Sets the lines of preview text to keep within the chat window without using internal or message archive. + - Sorting: + Allows sorting of recent messages by Time, or by status and time (with unavailable resources at the bottom). + *Messages* - Send messages on return: + If you are offline or away from connection, messages may be resent when you are back online or back in connection if this option is checked. + - Clear chat on close: + If this is enabled, when you close chats from the recent screen, all local history on the device will be deleted. This does not affect operation of offline or server-stored message archives. + - Message carbons: + Enables or disables message carbons to deliver to all resources. This is on by default, however some servers may not support this. + - Request delivery receipts: + Whether or not to request delivery receipts of messages sent. + *Attachments* - File sharing via HTTP: + This setting turns on the use of HTTP file sharing using the application. The server you are connected too must support this component to enable this option. - Simplified link to HTTP file: + This creates a simplified link to the file after uploading rather than directly sending the file. This may be useful for intermittent communications. + - Max image preview size: + Sets the maximum size of image previews to download before fully downloading files. Setting this at 0 prevents previews from retrieving files. + - Clear cache: + This clears the devices cache of all downloaded and saved files retrieved from HTTP upload component. + ==== Contacts *Display* - Contacts in groups: + Allows contacts to be displayed in groups as defined by the roster. Disabling this will show contacts in a flat organization. + - "Hidden" group: + Whether or not to display contacts that are added to the "hidden" group. + *General* - Auto-authorize contacts: + Selecting this will automatically request subscription to users added to contacts. + ==== Notifications This section has one option: Whether to accept notifications from unknown. If left disabled, notifications from unknown sources (including server administrators) will not be sent to the native notification section of the device. Instead, you will have to see them under the Recent menu. ================================================ FILE: Documentation/text/welcome.asciidoc ================================================ [[Welcome]] = Welcome :author: Tigase Team :toc: :numbered: :website: http://tigase.net Welcome to the documentation for Tigase Messenger for iOS. == Minimum Requirements Tigase Messenger for iOS requires an apple device running iOS v10 or later. Compatible devices are listed below: *iPhone* + - iPhone 5 + - iPhone 5C + - iPhone 5S + - iPhone 6 + - iPhone 6 Plus + - iPhone 6S + - iPhone 6S Plus + - iPhone 7 + - iPhone 7 Plus + - iPhone SE + *iPod Touch* + - iPod Touch (6th generation) *iPad* + - iPad (4th generation) + - iPad (5th generation) + - iPad Air + - iPad Air 2 + - iPad Mini 2 + - iPad Mini 3 + - iPad Mini 4 + - iPad Pro + == Installation Tigase Messenger for iOS can be installed the same way any apple approved app can be found: through the appstore. Search for Tigase in the store search function and then tap install and follow the prompts to install Tigase Messenger. == Account Setup Upon running Tigase Messenger for iOS for the first time, you will be greeted with the following screen: image::images\home.png[] Your options are to xref:reg[register] for a new account, or to use an xref:existing[existing] account. [register] === Registering for a New Account The application supports creating a new account registration using in-band registration. This means that on servers supporting it, you can sign up for a new account straight from the client! A list of servers that support this is located link:https://list.jabber.at/[here]. We have provided quick-links to Tigase maintained servers where you can register an account. However, you may use another domain if you wish. If you wish to use a custom domain, enter the domain address in the top bar, the application will then check with the server to ensure registration is supported. You will be presented with an error message if it is not supported. image::images\regfailure.png[] If registration is supported, you will see the following prompts: image::images\registernew.png[] Fill out the fields for username, password, and E-mail. You do not need to add the domain to your username, it will be added for you so your JID will look like `yourusername@domain.com` An E-mail is required in case a server administrator needs to get in contact with you, or you lose your password and might need recovery. Once you tap Register, the application will connect and register your account with the server. [existing] === Use an Existing Account If you already have an XMPP account on a server, select this option to login using Tigase Messenger for iOS. Enter your username and password as normal and tap Save to add the account. NOTE: Your device name will serve as the resource for your account. iPad or iPhone will automatically be used as the resource. === Certificate Errors You may receive certificate errors from servers that may not have certificate chains installed, invalid, or expired certificates. You will receive an unable to connect to server error, however servers with these errors will ask the user to accept or deny these security exceptions but they will show up at system notifications. After doing so you may reattempt the connection to the server. == Final Steps Once your account is verified, the application will log you in as online and display the recent screen. ================================================ FILE: NotificationService/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName NotificationService CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) NSExtension NSExtensionPointIdentifier com.apple.usernotifications.service NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).NotificationService ================================================ FILE: NotificationService/NotificationService.entitlements ================================================ com.apple.security.application-groups group.siskinim.shared group.siskinim.notifications ================================================ FILE: NotificationService/NotificationService.swift ================================================ // // NotificationService.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import BackgroundTasks import UserNotifications import UIKit import Shared import Martin import os.log import TigaseSQLite3 import Intents class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? { didSet { debug("content handler set!"); } } var bestAttemptContent: UNMutableNotificationContent? override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) debug("Received push!"); if let bestAttemptContent = bestAttemptContent { bestAttemptContent.sound = UNNotificationSound.default; bestAttemptContent.categoryIdentifier = "MESSAGE"; if let account = BareJID(bestAttemptContent.userInfo["account"] as? String) { DispatchQueue.main.async { let provider = ExtensionNotificationManagerProvider(); self.debug("push for account:", account); if let encryped = bestAttemptContent.userInfo["encrypted"] as? String, let ivStr = bestAttemptContent.userInfo["iv"] as? String { if let key = NotificationEncryptionKeys.key(for: account), let data = Data(base64Encoded: encryped), let iv = Data(base64Encoded: ivStr) { self.debug("got encrypted push with known key"); let cipher = Cipher.AES_GCM(); var decoded = Data(); if cipher.decrypt(iv: iv, key: key, encoded: data, auth: nil, output: &decoded) { self.debug("got decrypted data:", String(data: decoded, encoding: .utf8) as Any); if let payload = try? JSONDecoder().decode(Payload.self, from: decoded) { self.debug("decoded payload successfully!"); NotificationsManagerHelper.prepareNewMessageNotification(content: bestAttemptContent, account: account, sender: payload.sender.bareJid, nickname: payload.nickname, body: payload.message, provider: provider, completionHandler: { content in DispatchQueue.main.async { contentHandler(content); } }); return; } } } contentHandler(bestAttemptContent) } else { self.debug("got plain push with", bestAttemptContent.userInfo[AnyHashable("sender")] as? String as Any, bestAttemptContent.userInfo[AnyHashable("body")] as? String as Any, bestAttemptContent.userInfo[AnyHashable("unread-messages")] as? Int as Any, bestAttemptContent.userInfo[AnyHashable("nickname")] as? String as Any); NotificationsManagerHelper.prepareNewMessageNotification(content: bestAttemptContent, account: account, sender: JID(bestAttemptContent.userInfo[AnyHashable("sender")] as? String)?.bareJid, nickname: bestAttemptContent.userInfo[AnyHashable("nickname")] as? String, body: bestAttemptContent.userInfo[AnyHashable("body")] as? String, provider: provider, completionHandler: { content in DispatchQueue.main.async { contentHandler(content); } }); } } return; } else { contentHandler(bestAttemptContent); } } else { contentHandler(request.content); } // if #available(iOS 13.0, *) { // let taskRequest = BGAppRefreshTaskRequest(identifier: "org.tigase.messenger.mobile.refresh"); // taskRequest.earliestBeginDate = nil // do { // debug("scheduling background app refresh") // BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: "org.tigase.messenger.mobile.refresh") // try BGTaskScheduler.shared.submit(taskRequest); // } catch { // debug("Could not schedule app refresh: \(error)") // } // } } // func updateNotification(content: UNMutableNotificationContent, account: BareJID, unread: Int, sender: JID, type kind: Payload.Kind, nickname: String?, body: String) { // let tmp = try! DBConnection.main.prepareStatement(NotificationService.GET_NAME_QUERY).findFirst(["account": account, "jid": sender.bareJid] as [String: Any?], map: { (cursor) -> (String?, Int)? in // return (cursor["name"], cursor["type"]!); // }); // let name = tmp?.0; // let type: Payload.Kind = tmp?.1 == 1 ? .groupchat : .chat; // switch type { // case .chat: // content.title = name ?? sender.stringValue; // content.body = body; // content.userInfo = ["account": account.stringValue, "sender": sender.bareJid.stringValue]; // case .groupchat: // if let nickname = nickname { // content.title = "\(nickname) mentioned you in \(name ?? sender.bareJid.stringValue)"; // } else { // content.title = "\(name ?? sender.bareJid.stringValue)"; // } // content.body = body; // content.userInfo = ["account": account.stringValue, "sender": sender.bareJid.stringValue]; // default: // break; // } // content.categoryIdentifier = NotificationCategory.MESSAGE.rawValue; // //content.badge = 2; // // } func debug(_ data: Any...) { os_log("%{public}@", log: OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "SiskinPush"), "\(Date()): \(data)"); } override func serviceExtensionTimeWillExpire() { // Called just before the extension will be terminated by the system. // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { contentHandler(bestAttemptContent) } } } extension Database { private static var mainReaderInstance: DatabaseReader?; static func mainReader() throws -> DatabaseReader { if mainReaderInstance == nil { mainReaderInstance = try Database(path: Database.mainDatabaseUrl().path, flags: SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX); } return mainReaderInstance!; } } extension Query { static let buddyName = Query("select name from roster_items where account = :account and jid = :jid"); static let conversationNotificationDetails = Query("SELECT c.type as type, c.options as options FROM chats c WHERE c.account = :account AND c.jid = :jid") static let listUnreadThreads = Query("select c.account, c.jid from chats c inner join chat_history ch where ch.account = c.account and ch.jid = c.jid and ch.state in (2,6,7) group by c.account, c.jid"); static let findAvatar = Query("select ac.hash FROM avatars_cache ac WHERE ac.account = :account AND ac.jid = :jid ORDER BY ac.type ASC"); } class ExtensionNotificationManagerProvider: NotificationManagerProvider { static let GET_UNREAD_CHATS = "s"; func avatar(on account: BareJID, for sender: BareJID) -> INImage? { guard let hash = try? Database.mainReader().select(query: .findAvatar, params: ["account": account, "jid": sender]).mapFirst({ $0.string(for: "hash") }) else { return nil; } let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.siskinim.shared")!.appendingPathComponent("Library", isDirectory: true).appendingPathComponent("Caches", isDirectory: true).appendingPathComponent("avatars", isDirectory: true).appendingPathComponent(hash); return UIImage(contentsOfFile: url.path)?.inImage(); } func conversationNotificationDetails(for account: BareJID, with jid: BareJID, completionHandler: @escaping (ConversationNotificationDetails)->Void) { let (type, options) = try! Database.mainReader().select(query: .conversationNotificationDetails, cached: false, params: ["account": account, "jid": jid]).mapFirst({ cursor -> (ConversationType, ConversationOptions) in let type = ConversationType(rawValue: cursor.int(for: "type")!) ?? .chat; let options: ConversationOptions = cursor.object(for: "options") ?? ConversationOptions(); return (type, options); }) ?? (.chat, ConversationOptions()); switch type { case .chat: completionHandler(ConversationNotificationDetails(name: try! Database.mainReader().select(query: .buddyName, cached: false, params: ["account": account, "jid": jid]).mapFirst({ $0.string(for: "name") }) ?? jid.stringValue, notifications: options.notifications ?? .always, type: type, nick: nil)); case .channel, .room: completionHandler(ConversationNotificationDetails(name: options.name ?? jid.stringValue, notifications: options.notifications ?? .always, type: type, nick: options.nick)); } } func countBadge(withThreadId: String?, completionHandler: @escaping (Int) -> Void) { NotificationsManagerHelper.unreadChatsThreadIds { (result) in var unreadChats = result; try? Database.mainReader().select(query: .listUnreadThreads, cached: false, params: []).mapAll({ cursor in if let account = cursor.bareJid(for: "account"), let jid = cursor.bareJid(for: "jid") { return "account=\(account.stringValue)|sender=\(jid.stringValue)" } return nil; }).forEach({ unreadChats.insert($0) }); if let threadId = withThreadId { unreadChats.insert(threadId); } completionHandler(unreadChats.count); } completionHandler(-1); } func shouldShowNotification(account: BareJID, sender: BareJID?, body: String?, completionHandler: @escaping (Bool)->Void) { completionHandler(true); } } class Provider { } public struct ConversationOptions: Codable { var name: String?; var nick: String?; var notifications: ConversationNotification?; init(name: String? = nil, nick: String? = nil, notifications: ConversationNotification? = nil) { self.name = name; self.nick = nick; self.notifications = notifications; } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self); name = try container.decodeIfPresent(String.self, forKey: .name); nick = try container.decode(String.self, forKey: .nick); if let notificationsString = try container.decodeIfPresent(String.self, forKey: .notifications) { notifications = ConversationNotification(rawValue: notificationsString); } else { notifications = nil; } } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self); try container.encodeIfPresent(name, forKey: .name); try container.encodeIfPresent(nick, forKey: .nick); try container.encodeIfPresent(notifications?.rawValue, forKey: .notifications); } enum CodingKeys: String, CodingKey { case name = "name"; case notifications = "notifications"; case nick = "nick"; } } ================================================ FILE: README.md ================================================

SiskinIM

The XMPP client for iOS

# What it is Siskin IM by Tigase, Inc. is a lightweight and powerful XMPP client for iPhone and iPad. # Features Siskin IM is easy to use and lightweight XMPP client. It has support for file and image sharing, group chats, end-to-end encryption and many [more](https://siskin.im). # Support When looking for support, please first search existing issues and pull-requests. If you didn't find an answer in the resources above, feel free to submit your question as [new issue on GitHub](https://github.com/tigase/siskin-im/issues/new/choose). # Downloads Sikin IM may be downloaded from the [App Store](https://itunes.apple.com/us/app/tigase-messenger/id1153516838). ## TestFlight **WARNING: Testing version of the Siskin can be unstable and cause data loss, you are using it at your discretion.** To sign up to TestFlight beta testing please use following link: https://testflight.apple.com/join/zDSl1D3s to join the testing program. # Using software After installation of Siskin IM it will suggest you to add or register the XMPP account, which you should do. After that you can start chatting with your friends using your XMPP account. # License Tigase Tigase Logo Official Tigase repository is available at: https://github.com/tigase/siskin-im/. Copyright (c) 2004 Tigase, Inc. Licensed under GPL License Version 3. Other licensing options available upon request. ================================================ FILE: Shared/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString 1.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) ================================================ FILE: Shared/NotificationCategory.swift ================================================ // // NotificationCategory.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation public enum NotificationCategory: String { case UNKNOWN case ERROR case MESSAGE case SUBSCRIPTION_REQUEST case MUC_ROOM_INVITATION case CALL case UNSENT_MESSAGES public static func from(identifier: String?) -> NotificationCategory { guard let str = identifier else { return .UNKNOWN; } return NotificationCategory(rawValue: str) ?? .UNKNOWN; } } ================================================ FILE: Shared/Shared.h ================================================ // // Shared.h // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // #import //! Project version number for Shared. //FOUNDATION_EXPORT double SharedVersionNumber; //! Project version string for Shared. //FOUNDATION_EXPORT const unsigned char SharedVersionString[]; // In this header, you should import all the public headers of your framework using statements like #import ================================================ FILE: Shared/database/ConversationType.swift ================================================ // // ConversationType.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation public enum ConversationType: Int { case chat = 0 case room = 1 case channel = 2 } ================================================ FILE: Shared/database/Database.swift ================================================ // // Database.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import TigaseSQLite3 import Martin extension Database { public static func mainDatabaseUrl() -> URL { return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.siskinim.shared")!.appendingPathComponent("siskinim_main.db"); } } extension JID: DatabaseConvertibleStringValue { public func encode() -> String { return self.stringValue; } } extension BareJID: DatabaseConvertibleStringValue { public func encode() -> String { return self.stringValue; } } extension Element: DatabaseConvertibleStringValue { public func encode() -> String { return self.stringValue; } } extension Cursor { public func jid(for column: String) -> JID? { return JID(string(for: column)); } public func jid(at column: Int) -> JID? { return JID(string(at: column)); } public subscript(index: Int) -> JID? { return JID(string(at: index)); } public subscript(column: String) -> JID? { return JID(string(for: column)); } } extension Cursor { public func bareJid(for column: String) -> BareJID? { return BareJID(string(for: column)); } public func bareJid(at column: Int) -> BareJID? { return BareJID(string(at: column)); } public subscript(index: Int) -> BareJID? { return BareJID(string(at: index)); } public subscript(column: String) -> BareJID? { return BareJID(string(for: column)); } } ================================================ FILE: Shared/notifications/ConversationNotifications.swift ================================================ // // ConversationNotifications.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation public enum ConversationNotification: String { case none case mention case always } ================================================ FILE: Shared/notifications/NotificationEncryptionKeys.swift ================================================ // // NotificationEncryptionKeys.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin public class NotificationEncryptionKeys { private static let storage = UserDefaults(suiteName: "group.siskinim.notifications")!; public static func key(for account: BareJID) -> Data? { storage.data(forKey: account.stringValue) } public static func set(key: Data?, for account: BareJID) { storage.setValue(key, forKey: account.stringValue); } } ================================================ FILE: Shared/notifications/NotificationsManagerHelper.swift ================================================ // // NotificationsManagerHelper.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import UserNotifications import os import Intents import UIKit public struct ConversationNotificationDetails { public let name: String; public let notifications: ConversationNotification; public let type: ConversationType; public let nick: String?; public init(name: String, notifications: ConversationNotification, type: ConversationType, nick: String?) { self.name = name; self.notifications = notifications; self.type = type; self.nick = nick; } } public class NotificationsManagerHelper { public static func unreadChatsThreadIds(completionHandler: @escaping (Set)->Void) { unreadThreadIds(for: [.MESSAGE], completionHandler: completionHandler); } public static func unreadThreadIds(for categories: [NotificationCategory], completionHandler: @escaping (Set)->Void) { UNUserNotificationCenter.current().getDeliveredNotifications { (notifications) in let unreadChats = Set(notifications.filter({(notification) in let category = NotificationCategory.from(identifier: notification.request.content.categoryIdentifier); return categories.contains(category); }).map({ (notification) in return notification.request.content.threadIdentifier; })); completionHandler(unreadChats); } } public static func generateMessageUID(account: BareJID, sender: BareJID?, body: String?) -> String? { if let sender = sender, let body = body { return Digest.sha256.digest(toHex: "\(account)|\(sender)|\(body)".data(using: .utf8)); } return nil; } public static func prepareNewMessageNotification(content: UNMutableNotificationContent, account: BareJID, sender jid: BareJID?, nickname: String?, body msg: String?, provider: NotificationManagerProvider, completionHandler: @escaping (UNNotificationContent)->Void) { let timestamp = Date(); content.sound = .default; content.categoryIdentifier = NotificationCategory.MESSAGE.rawValue; if let sender = jid, let body = msg { let uid = generateMessageUID(account: account, sender: sender, body: body)!; content.threadIdentifier = "account=\(account.stringValue)|sender=\(sender.stringValue)"; provider.conversationNotificationDetails(for: account, with: sender, completionHandler: { details in os_log("%{public}@", log: OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "SiskinPush"), "Found: name: \(details.name), type: \(String(describing: details.type.rawValue))"); var senderId: String = sender.stringValue; var group: INSpeakableString?; switch details.type { case .chat: content.title = details.name; if body.starts(with: "/me ") { content.body = String(body.dropFirst(4)); } else { content.body = body; } case .channel, .room: content.title = details.name group = INSpeakableString(spokenPhrase: details.name); if body.starts(with: "/me ") { if let nickname = nickname { content.body = "\(nickname) \(body.dropFirst(4))"; } else { content.body = String(body.dropFirst(4)); } } else { content.body = body; if let nickname = nickname { content.subtitle = nickname; senderId = sender.with(resource: nickname).stringValue; } } } content.userInfo = ["account": account.stringValue, "sender": sender.stringValue, "uid": uid, "timestamp": timestamp]; provider.countBadge(withThreadId: content.threadIdentifier, completionHandler: { count in content.badge = count as NSNumber; if #available(iOS 15.0, *) { do { let recipient = INPerson(personHandle: INPersonHandle(value: account.stringValue, type: .unknown), nameComponents: nil, displayName: nil, image: nil, contactIdentifier: nil, customIdentifier: nil, isMe: true, suggestionType: .none); let avatar = provider.avatar(on: account, for: sender); let sender = INPerson(personHandle: INPersonHandle(value: senderId, type: .unknown), nameComponents: nil, displayName: group == nil ? details.name : nickname, image: avatar, contactIdentifier: nil, customIdentifier: senderId, isMe: false, suggestionType: .instantMessageAddress); let intent = INSendMessageIntent(recipients: group == nil ? [recipient] : [recipient, sender], outgoingMessageType: .outgoingMessageText, content: nil, speakableGroupName: group, conversationIdentifier: content.threadIdentifier, serviceName: "Siskin IM", sender: sender, attachments: nil); if details.type == .chat { intent.setImage(avatar, forParameterNamed: \.sender); } else { intent.setImage(avatar, forParameterNamed: \.speakableGroupName); } let interaction = INInteraction(intent: intent, response: nil); interaction.direction = .incoming; interaction.donate(completion: nil); completionHandler(try content.updating(from: intent)); } catch { // some error happened completionHandler(content); } } else { completionHandler(content); } }); }) } else { content.threadIdentifier = "account=\(account.stringValue)"; content.body = NSLocalizedString("New message!", comment: "new message without content notification"); provider.countBadge(withThreadId: content.threadIdentifier, completionHandler: { count in content.badge = count as NSNumber; completionHandler(content); }); } } } public protocol NotificationManagerProvider { func conversationNotificationDetails(for account: BareJID, with jid: BareJID, completionHandler: @escaping (ConversationNotificationDetails)->Void); func countBadge(withThreadId: String?, completionHandler: @escaping (Int)->Void); func shouldShowNotification(account: BareJID, sender: BareJID?, body: String?, completionHandler: @escaping (Bool)->Void); func avatar(on account: BareJID, for sender: BareJID) -> INImage?; } public class Payload: Decodable { public var unread: Int; public var sender: JID; public var type: Kind; public var nickname: String?; public var message: String?; public var sid: String?; public var media: [String]?; required public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self); unread = try container.decode(Int.self, forKey: .unread); sender = try container.decode(JID.self, forKey: .sender); type = Kind(rawValue: (try container.decodeIfPresent(String.self, forKey: .type)) ?? Kind.unknown.rawValue)!; nickname = try container.decodeIfPresent(String.self, forKey: .nickname); message = try container.decodeIfPresent(String.self, forKey: .message); sid = try container.decodeIfPresent(String.self, forKey: .sid) media = try container.decodeIfPresent([String].self, forKey: .media); // -- and so on... } public enum Kind: String { case unknown case groupchat case chat case call } public enum CodingKeys: String, CodingKey { case unread case sender case type case nickname case message case sid case media } } ================================================ FILE: Shared/ui/UIImage.swift ================================================ // // UIImage.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Intents extension UIImage { public func scaled(maxWidthOrHeight: CGFloat, isOpaque: Bool = false) -> UIImage? { guard maxWidthOrHeight < size.height || maxWidthOrHeight < size.width else { return self; } let newSize = size.height > size.width ? CGSize(width: (size.width / size.height) * maxWidthOrHeight, height: maxWidthOrHeight) : CGSize(width: maxWidthOrHeight, height: (size.height / size.width) * maxWidthOrHeight); let format = imageRendererFormat; if isOpaque { format.opaque = isOpaque; } return UIGraphicsImageRenderer(size: newSize, format: format).image { _ in draw(in: CGRect(origin: .zero, size: newSize)); }; // UIGraphicsBeginImageContextWithOptions(newSize, false, 0); // self.imageRendererFormat // self.draw(in: CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height)); // defer { // UIGraphicsEndImageContext(); // } // return UIGraphicsGetImageFromCurrentImageContext(); } public func inImage() -> INImage? { guard let data = self.jpegData(compressionQuality: 0.7) else { return nil; } return INImage(imageData: data); } } ================================================ FILE: Shared/util/HTTPFileUploadHelper.swift ================================================ // // HTTPFileUploadHelper.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import TigaseLogging open class HTTPFileUploadHelper { private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HTTPFileUploadHelper") public static func upload(for context: Context, filename: String, inputStream: InputStream, filesize size: Int, mimeType: String, delegate: URLSessionDelegate?, completionHandler: @escaping (Result)->Void) { let httpUploadModule = context.module(.httpFileUpload); httpUploadModule.findHttpUploadComponent(completionHandler: { result in switch result { case .success(let components): guard let component = components.first(where: { $0.maxSize > size }) else { completionHandler(.failure(.fileTooBig)); return; } httpUploadModule.requestUploadSlot(componentJid: component.jid, filename: filename, size: size, contentType: mimeType, completionHandler: { result in switch result { case .success(let slot): var request = URLRequest(url: slot.putUri); slot.putHeaders.forEach({ (k,v) in request.addValue(v, forHTTPHeaderField: k); }); request.httpMethod = "PUT"; request.httpBodyStream = inputStream; request.addValue(String(size), forHTTPHeaderField: "Content-Length"); request.addValue(mimeType, forHTTPHeaderField: "Content-Type"); let session = URLSession(configuration: URLSessionConfiguration.default, delegate: delegate, delegateQueue: OperationQueue.main); session.dataTask(with: request) { (data, response, error) in let code = (response as? HTTPURLResponse)?.statusCode ?? 500; guard error == nil && (code == 200 || code == 201) else { logger.error("upload of file \(filename) failed, error: \(error as Any), response: \(response as Any)"); completionHandler(.failure(.httpError)); return; } if code == 200 { completionHandler(.failure(.invalidResponseCode(url: slot.getUri))); } else { completionHandler(.success(slot.getUri)); } }.resume(); case .failure(let error): logger.error("upload of file \(filename) failed, upload component returned error: \(error as Any)"); completionHandler(.failure(.unknownError)); } }); case .failure(let error): completionHandler(.failure(error.errorCondition == .item_not_found ? .notSupported : .unknownError)); } }) } public enum UploadResult { case success(url: URL, filesize: Int, mimeType: String?) case failure(ShareError) } } ================================================ FILE: Shared/util/ImageQuality.swift ================================================ // // ImageQuality.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit public enum ImageQuality: String { case original case highest case high case medium case low public var label: String { switch self { case .original: return NSLocalizedString("Original", comment: "video quality") case .highest: return NSLocalizedString("Highest", comment: "video quality") case .high: return NSLocalizedString("High", comment: "video quality") case .medium: return NSLocalizedString("Medium", comment: "video quality") case .low: return NSLocalizedString("Low", comment: "video quality") } } public var size: CGFloat { switch self { case .original: return CGFloat.greatestFiniteMagnitude; case .highest: return CGFloat.greatestFiniteMagnitude; case .high: return 2048; case .medium: return 1536; case .low: return 1024; } } public var quality: CGFloat { switch self { case .original: return 1; case .highest: return 1; case .high: return 0.85; case .medium: return 0.7; case .low: return 0.6; } } } ================================================ FILE: Shared/util/MediaHelper.swift ================================================ // // MediaHelper.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import AVKit public struct ShareFileInfo { public let filename: String; public let suffix: String?; public var filenameWithSuffix: String { if let suffix = suffix { return "\(filename).\(suffix)"; } else { return filename; } } public init(filename: String, suffix: String?) { self.filename = filename; if suffix?.isEmpty ?? true { self.suffix = nil; } else { self.suffix = suffix; } } public func with(suffix: String?) -> ShareFileInfo { return .init(filename: filename, suffix: suffix); } public func with(filename: String) -> String { if let suffix = self.suffix { return "\(filename).\(suffix)"; } return filename; } public static func from(url: URL, defaultSuffix: String?) -> ShareFileInfo { let name = url.lastPathComponent; var startOffset = name.startIndex; if name.hasPrefix("trim.") { startOffset = name.index(startOffset, offsetBy: "trim.".count); } if let idx = name.lastIndex(of: "."), idx > startOffset { return ShareFileInfo(filename: String(name[startOffset..)->Void) { guard quality != .original else { let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent(fileInfo.with(filename: UUID().uuidString)); do { try FileManager.default.copyItem(at: url, to: tempUrl); } catch { completionHandler(.failure(.noAccessError)) return; } completionHandler(.success((tempUrl, fileInfo))); return; } guard let inData = try? Data(contentsOf: url), let image = UIImage(data: inData) else { completionHandler(.failure(.notSupported)); return; } compressImage(image: image, fileInfo: fileInfo, quality: quality, completionHandler: completionHandler); } public static func compressImage(image: UIImage, fileInfo: ShareFileInfo, quality: ImageQuality, completionHandler: @escaping(Result<(URL,ShareFileInfo),ShareError>)->Void) { let newFileInfo = fileInfo.with(suffix: "jpg"); let fileUrl = FileManager.default.temporaryDirectory.appendingPathComponent(newFileInfo.filenameWithSuffix, isDirectory: false); guard let outData = image.scaled(maxWidthOrHeight: quality.size)?.jpegData(compressionQuality: quality.quality) else { return; } do { try outData.write(to: fileUrl); completionHandler(.success((fileUrl,newFileInfo))); } catch { completionHandler(.failure(.noAccessError)); return; } } public static func compressMovie(url: URL, fileInfo: ShareFileInfo, quality: VideoQuality, progressCallback: @escaping (Float)->Void, completionHandler: @escaping (Result<(URL,ShareFileInfo),Error>)->Void) { guard quality != .original else { let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent(fileInfo.with(filename: UUID().uuidString)); do { try FileManager.default.copyItem(at: url, to: tempUrl); } catch { completionHandler(.failure(ShareError.noAccessError)) return; } completionHandler(.success((tempUrl,fileInfo))); return; } let video = AVAsset(url: url); let exportSession = AVAssetExportSession(asset: video, presetName: quality.preset)!; exportSession.shouldOptimizeForNetworkUse = true; exportSession.outputFileType = .mp4; let newFileInfo = fileInfo.with(suffix: "mp4"); let fileUrl = FileManager.default.temporaryDirectory.appendingPathComponent(newFileInfo.filenameWithSuffix, isDirectory: false); exportSession.outputURL = fileUrl; let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { _ in progressCallback(exportSession.progress); }) exportSession.exportAsynchronously { timer.invalidate(); if let error = exportSession.error { completionHandler(.failure(error)); } else { completionHandler(.success((fileUrl,newFileInfo))); } } } } public enum ShareError: Error, LocalizedError { case unknownError case noAccessError case noFileSizeError case noMimeTypeError case notSupported case fileTooBig case httpError case invalidResponseCode(url: URL) public var message: String { switch self { case .invalidResponseCode: return NSLocalizedString("Server did not confirm file upload correctly.", comment: "sharing error") case .unknownError: return NSLocalizedString("Please try again later.", comment: "sharing error") case .noAccessError: return NSLocalizedString("It was not possible to access the file.", comment: "sharing error") case .noFileSizeError: return NSLocalizedString("Could not retrieve file size.", comment: "sharing error") case .noMimeTypeError: return NSLocalizedString("Could not detect MIME type of a file.", comment: "sharing error") case .notSupported: return NSLocalizedString("Feature not supported by XMPP server", comment: "sharing error") case .fileTooBig: return NSLocalizedString("File is too big to share", comment: "sharing error") case .httpError: return NSLocalizedString("Upload to HTTP server failed.", comment: "sharing error") } } } ================================================ FILE: Shared/util/VideoQuality.swift ================================================ // // VideoQuality.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import AVKit public enum VideoQuality: String { case original case high case medium case low public var label: String { switch self { case .original: return NSLocalizedString("Original", comment: "video quality") case .high: return NSLocalizedString("High", comment: "video quality") case .medium: return NSLocalizedString("Medium", comment: "video quality") case .low: return NSLocalizedString("Low", comment: "video quality") } } public var preset: String { switch self { case .original: return AVAssetExportPresetPassthrough; case .high: return AVAssetExportPresetHighestQuality; case .medium: return AVAssetExportPresetMediumQuality; case .low: return AVAssetExportPresetLowQuality; } } } ================================================ FILE: Shared/util/crypto/Cipher+AES.swift ================================================ // // Cipher+AES.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import OpenSSL import TigaseLogging open class Cipher { } extension Cipher { open class AES_GCM { private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "aesgcm") public init() { } public static func generateKey(ofSize: Int) -> Data? { var key = Data(count: ofSize/8); let result = key.withUnsafeMutableBytes({ (ptr: UnsafeMutableRawBufferPointer) -> Int32 in return SecRandomCopyBytes(kSecRandomDefault, ofSize/8, ptr.baseAddress!); }); guard result == errSecSuccess else { AES_GCM.logger.error("failed to generated AES encryption key: \(result)"); return nil; } return key; } open func encrypt(iv: Data, key: Data, message data: Data, output: UnsafeMutablePointer?, tag: UnsafeMutablePointer?) -> Bool { let ctx = EVP_CIPHER_CTX_new(); EVP_EncryptInit_ex(ctx, key.count == 32 ? EVP_aes_256_gcm() : EVP_aes_128_gcm(), nil, nil, nil); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, Int32(iv.count), nil); iv.withUnsafeBytes({ (ivBytes: UnsafeRawBufferPointer) -> Void in key.withUnsafeBytes({ (keyBytes: UnsafeRawBufferPointer) -> Void in EVP_EncryptInit_ex(ctx, nil, nil, keyBytes.baseAddress!.assumingMemoryBound(to: UInt8.self), ivBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)); }) }); EVP_CIPHER_CTX_set_padding(ctx, 1); var outbuf = Array(repeating: UInt8(0), count: data.count); var outbufLen: Int32 = 0; let encryptedBody = data.withUnsafeBytes { ( bytes) -> Data in EVP_EncryptUpdate(ctx, &outbuf, &outbufLen, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), Int32(data.count)); return Data(bytes: &outbuf, count: Int(outbufLen)); } EVP_EncryptFinal_ex(ctx, &outbuf, &outbufLen); var tagData = Data(count: 16); tagData.withUnsafeMutableBytes({ (bytes) -> Void in EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self)); }); EVP_CIPHER_CTX_free(ctx); tag?.initialize(to: tagData); output?.initialize(to: encryptedBody); return true; } open func encrypt(iv: Data, key: Data, provider: CipherDataProvider, consumer: CipherDataConsumer, chunkSize: Int = 512*1024) -> Data { let ctx = EVP_CIPHER_CTX_new(); EVP_EncryptInit_ex(ctx, key.count == 32 ? EVP_aes_256_gcm() : EVP_aes_128_gcm(), nil, nil, nil); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, Int32(iv.count), nil); iv.withUnsafeBytes({ (ivBytes: UnsafeRawBufferPointer) -> Void in key.withUnsafeBytes({ (keyBytes: UnsafeRawBufferPointer) -> Void in EVP_EncryptInit_ex(ctx, nil, nil, keyBytes.baseAddress!.assumingMemoryBound(to: UInt8.self), ivBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)); }) }); EVP_CIPHER_CTX_set_padding(ctx, 1); var ended: Bool = false; var buffer = Data(count: chunkSize * 2); repeat { switch provider.chunk(size: chunkSize) { case .data(let data): let result = data.withUnsafeBytes { ( bytes) -> Data in let wrote = buffer.withUnsafeMutableBytes { (outbuf) -> Int in var outbufLen: Int32 = 0; EVP_EncryptUpdate(ctx, outbuf.baseAddress!.assumingMemoryBound(to: UInt8.self), &outbufLen, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), Int32(data.count)); return Int(outbufLen) } return buffer.subdata(in: 0.. Void in var outbufLen: Int32 = 0; EVP_EncryptFinal_ex(ctx, outbuf.baseAddress!.assumingMemoryBound(to: UInt8.self), &outbufLen); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, outbuf.baseAddress!.assumingMemoryBound(to: UInt8.self)); } ended = true; } } while !ended; return buffer.subdata(in: 0..<16); } open func decrypt(iv: Data, key: Data, encoded payload: Data, auth tag: Data?, output: UnsafeMutablePointer?) -> Bool { let ctx = EVP_CIPHER_CTX_new(); EVP_DecryptInit_ex(ctx, key.count == 32 ? EVP_aes_256_gcm() : EVP_aes_128_gcm(), nil, nil, nil); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, Int32(iv.count), nil); key.withUnsafeBytes({ (keyBytes) -> Void in iv.withUnsafeBytes({ (ivBytes) -> Void in EVP_DecryptInit_ex(ctx, nil, nil, keyBytes.baseAddress!.assumingMemoryBound(to: UInt8.self), ivBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)); }) }) EVP_CIPHER_CTX_set_padding(ctx, 1); var auth = tag; var encoded = payload; if auth == nil { auth = payload.subdata(in: (payload.count - 16).. Data in EVP_DecryptUpdate(ctx, &outbuf, &outbufLen, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), Int32(encoded.count)); return Data(bytes: &outbuf, count: Int(outbufLen)); }); if auth != nil { auth!.withUnsafeMutableBytes({ [count = auth!.count] (bytes: UnsafeMutableRawBufferPointer) -> Void in EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_CCM_SET_TAG, Int32(count), bytes.baseAddress!.assumingMemoryBound(to: UInt8.self)); }); } let ret = EVP_DecryptFinal_ex(ctx, &outbuf, &outbufLen); EVP_CIPHER_CTX_free(ctx); guard ret >= 0 else { AES_GCM.logger.error("authentication of encrypted message failed: \(ret)"); return false; } output?.initialize(to: decoded); return true; } open func decrypt(iv: Data, key: Data, provider: CipherDataProvider, consumer: CipherDataConsumer, chunkSize: Int = 512 * 1024) -> Bool { let ctx = EVP_CIPHER_CTX_new(); EVP_DecryptInit_ex(ctx, key.count == 32 ? EVP_aes_256_gcm() : EVP_aes_128_gcm(), nil, nil, nil); EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, Int32(iv.count), nil); key.withUnsafeBytes({ (keyBytes) -> Void in iv.withUnsafeBytes({ (ivBytes) -> Void in EVP_DecryptInit_ex(ctx, nil, nil, keyBytes.baseAddress!.assumingMemoryBound(to: UInt8.self), ivBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)); }) }) EVP_CIPHER_CTX_set_padding(ctx, 1); var ended: Bool = false; var buffer = Data(count: chunkSize * 2); var result = false; repeat { switch provider.chunk(size: chunkSize) { case .data(let data): let result = data.withUnsafeBytes { ( bytes) -> Data in let wrote = buffer.withUnsafeMutableBytes { (outbuf) -> Int in var outbufLen: Int32 = 0; EVP_DecryptUpdate(ctx, outbuf.baseAddress!.assumingMemoryBound(to: UInt8.self), &outbufLen, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), Int32(data.count)); return Int(outbufLen) } return buffer.subdata(in: 0.. Void in EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_CCM_SET_TAG, Int32(count), bytes.baseAddress!.assumingMemoryBound(to: UInt8.self)); }); } result = buffer.withUnsafeMutableBytes { (outbuf) -> Bool in var outbufLen: Int32 = 0; return EVP_DecryptFinal_ex(ctx, outbuf.baseAddress!.assumingMemoryBound(to: UInt8.self), &outbufLen) >= 0; } ended = true; } } while !ended; return result; } } public class DataDataProvider: CipherDataProvider { let data: Data; private(set) var offset: Int = 0; public init(data: Data) { self.data = data; } public func chunk(size chunkSize: Int) -> Cipher.DataProviderResult { guard offset < data.count else { return .ended; } let size = min(chunkSize, data.count - offset); defer { offset = offset + size; } return .data(data.subdata(in: offset..<(offset + size))); } } public class FileDataProvider: CipherDataProviderWithAuth { let inputStream: InputStream; var count: Int = 0; var limit: Int = 0; public convenience init(inputStream: InputStream, fileSize: Int, hasAuthTag: Bool) { if hasAuthTag { self.init(inputStream: inputStream, limit: fileSize - 16); } else { self.init(inputStream: inputStream); } } public init(inputStream: InputStream, limit: Int = Int.max) { self.limit = limit; self.inputStream = inputStream; self.inputStream.open(); } deinit { self.inputStream.close(); } public func chunk(size: Int) -> Cipher.DataProviderResult { guard inputStream.hasBytesAvailable else { inputStream.close(); return .ended; } let limit = min(size, self.limit - self.count); guard limit > 0 else { return .ended; } var buf = Array(repeating: UInt8(0), count: limit); let read = inputStream.read(&buf, maxLength: limit); guard read > 0 else { return .ended; } count = count + read; return .data(Data(bytes: &buf, count: read)); } public func authTag() -> Data? { guard inputStream.hasBytesAvailable else { return nil; } var buf = Array(repeating: UInt8(0), count: 16); let read = inputStream.read(&buf, maxLength: 16); guard read > 0 else { return nil; } return Data(bytes: &buf, count: read); } } public class TempFileConsumer: CipherDataConsumer { public let url: URL; private var outputStream: OutputStream?; public private(set) var size: Int = 0; public init?() { self.url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString); guard let outputStream = OutputStream(url: url, append: true) else { return nil; } self.outputStream = outputStream; self.outputStream?.open(); guard self.outputStream != nil, self.outputStream!.hasSpaceAvailable else { return nil; } } public func consume(data: Data) -> Int { let wrote = data.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> Int in return outputStream!.write(ptr.bindMemory(to: UInt8.self).baseAddress!, maxLength: data.count); } size = size + wrote; return wrote; } public func close() { self.outputStream!.close(); } deinit { if outputStream != nil { outputStream?.close(); } try? FileManager.default.removeItem(at: url); } } public enum DataProviderResult { case data(Data) case ended } } public protocol CipherDataProvider { func chunk(size: Int) -> Cipher.DataProviderResult; } public protocol CipherDataProviderWithAuth: CipherDataProvider { func authTag() -> Data?; } public protocol CipherDataConsumer { func consume(data: Data) -> Int; } ================================================ FILE: Shared/util/crypto/SSLCertificate.swift ================================================ // // SSLCertificate.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import OpenSSL open class SSLCertificate { private let ref: OpaquePointer; init(withOwnedReference ref: OpaquePointer) { self.ref = ref; } deinit { X509_free(ref); } open func derCertificateData() -> Data? { var buf: UnsafeMutablePointer? = nil; let len = i2d_X509(self.ref, &buf); guard len >= 0 else { return nil; } defer { X509_free(OpaquePointer.init(buf)); } return Data(bytes: UnsafeRawPointer(buf!), count: Int(len)); } open func secCertificate() -> SecCertificate? { guard let data = derCertificateData() else { return nil; } return SecCertificateCreateWithData(nil, data as CFData); } open func secTrust() -> SecTrust? { guard let cert = secCertificate() else { return nil; } var commonName: CFString?; SecCertificateCopyCommonName(cert, &commonName); var trust: SecTrust?; guard SecTrustCreateWithCertificates([cert] as CFArray, SecPolicyCreateBasicX509(), &trust) == errSecSuccess else { return nil; } return trust; } } ================================================ FILE: Shared/util/crypto/SSLContext.swift ================================================ // // SSLContext.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import OpenSSL open class SSLContext { private let sslContext: OpaquePointer; public init?(alpnProtocols: [String] = [], supportedTlsVersions: ClosedRange = SSLProtocol.TLSv1_2...SSLProtocol.TLSv1_3) { guard let context = SSL_CTX_new(TLS_client_method()) else { return nil; } sslContext = context; if !alpnProtocols.isEmpty { var bytes = alpnProtocols.map { SSLContext.protocolToBytes($0) }.reduce(into: [UInt8](repeating: 0, count: 0), { result, value in result.append(contentsOf: value) }); SSL_CTX_set_alpn_protos(context, &bytes, UInt32(bytes.count)); } var options = UInt(0) | UInt(SSL_OP_NO_SSLv2) | UInt(SSL_OP_NO_SSLv3) | UInt(SSL_OP_NO_COMPRESSION); for version in SSLProtocol.allCases { if !supportedTlsVersions.contains(version) { options = options | UInt(version.ssl_op_no); } } SSL_CTX_set_options(context, options) SSL_CTX_ctrl(context, SSL_CTRL_SET_SESS_CACHE_MODE, Int(SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_NO_INTERNAL_STORE), nil); } deinit { SSL_CTX_free(sslContext); } open func createConnection() -> SSLProcessor? { guard let ssl = SSL_new(sslContext) else { return nil; } return SSLProcessor(ssl: ssl, context: self); } private static func protocolToBytes(_ proto: String) -> [UInt8] { let data = proto.data(using: .utf8)!; var bytes: [UInt8] = [UInt8](repeating: 0, count: data.count); data.copyBytes(to: &bytes, count: data.count); return [UInt8(data.count)] + bytes; } } ================================================ FILE: Shared/util/crypto/SSLProcessor.swift ================================================ // // SSLProcessor.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import OpenSSL import TigaseLogging public enum TLSVersion: Comparable, CaseIterable { case TLSv1 case TLSv1_1 case TLSv1_2 case TLSv1_3 case unknown } extension TLSVersion { var ssl_op_no: UInt32 { switch self { case .TLSv1: return SSL_OP_NO_TLSv1; case .TLSv1_1: return SSL_OP_NO_TLSv1_1; case .TLSv1_2: return SSL_OP_NO_TLSv1_2; case .TLSv1_3: return SSL_OP_NO_TLSv1_3; case .unknown: return 0; } } } public typealias SSLProtocol = TLSVersion; extension SSLProtocol { static func from(protocolId: Int32) -> SSLProtocol { switch protocolId { case TLS1_VERSION: return .TLSv1; case TLS1_1_VERSION: return .TLSv1_1; case TLS1_2_VERSION: return .TLSv1_2; case TLS1_3_VERSION: return .TLSv1_3; default: return .unknown; } } var name: String { switch self { case .TLSv1: return "TLSv1" case .TLSv1_1: return "TLSv1.1" case .TLSv1_2: return "TLSv1.2" case .TLSv1_3: return "TLSv1.3" case .unknown: return "Unknown"; } } } extension SecTrustResultType { var name: String { switch self { case .deny: return "deny"; case .fatalTrustFailure: return "fatal trust failure"; case .invalid: return "invalid"; case .otherError: return "other error"; case .proceed: return "proceed"; case .recoverableTrustFailure: return "recoverable trust failure"; case .unspecified: return "unspecified"; default: return "unknown" } } } open class SSLProcessor: ConnectorBase.NetworkProcessor, SSLNetworkProcessor { enum HandshakeResult { case complete case incomplete case failed static func from(code: Int32, connection: SSLProcessor) -> HandshakeResult { guard code != 1 else { return .complete; } let status = SSLStatus.from(code: code, connection: connection); switch status { case .want_read, .want_write: return .incomplete; default: return .failed; } } } enum State { case handshaking case active case closed } enum Operation { case write case read case handshake } enum SSLError: Error { case unknown case closed } enum SSLStatus { case ok case want_read case want_write case fail static func from(code: Int32, connection: SSLProcessor) -> SSLStatus { guard code != 0 else { return .ok; } let status = SSL_get_error(connection.ssl, code); switch status { case SSL_ERROR_NONE: return .ok; case SSL_ERROR_WANT_READ: return .want_read; case SSL_ERROR_WANT_WRITE: return .want_write; default: return .fail; } } } fileprivate let ssl: OpaquePointer; private var state: State = .handshaking; private var readBio: OpaquePointer; private var writeBio: OpaquePointer; public var serverName: String? { didSet { _ = serverName?.withCString({ SSL_ctrl(ssl, SSL_CTRL_SET_TLSEXT_HOSTNAME, Int(TLSEXT_NAMETYPE_host_name), UnsafeMutableRawPointer(mutating: $0)); }) } } public var certificateValidation: SSLCertificateValidation = .default; public var certificateValidationFailed: ((SecTrust?)->Void)?; private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "SSLProcessor"); public init?(ssl: OpaquePointer, context: SSLContext) { self.ssl = ssl; self.readBio = BIO_new(BIO_s_mem()); self.writeBio = BIO_new(BIO_s_mem()); SSL_set_bio(ssl, readBio, writeBio); SSL_set_connect_state(ssl); } private static func protocolToBytes(_ proto: String) -> [UInt8] { let data = proto.data(using: .utf8)!; var bytes: [UInt8] = [UInt8](repeating: 0, count: data.count); data.copyBytes(to: &bytes, count: data.count); return [UInt8(data.count)] + bytes; } deinit { SSL_free(ssl); } open func setALPNProtocols(_ alpnProtocols: [String]) { if !alpnProtocols.isEmpty { var bytes = alpnProtocols.map { SSLProcessor.protocolToBytes($0) }.reduce(into: [UInt8](repeating: 0, count: 0), { result, value in result.append(contentsOf: value) }); SSL_set_alpn_protos(ssl, &bytes, UInt32(bytes.count)); } } private func status(code n: Int32) -> SSLStatus { let status = SSL_get_error(ssl, n); switch status { case SSL_ERROR_NONE: return .ok; case SSL_ERROR_WANT_READ: return .want_read; case SSL_ERROR_WANT_WRITE: return .want_write; default: return .fail; } } open override func read(data: Data) { try? decrypt(data: data); } open override func write(data: Data, completion: WriteCompletion) { encrypt(data: data, completion: completion); } open func decrypt(data: Data) throws { guard data.withUnsafeBytes({ bufPtr in return BIO_write(readBio, bufPtr.baseAddress!, Int32(data.count)); }) == Int32(data.count) else { throw SSLError.closed; } switch state { case .handshaking: doHandshaking(); case .active: readDataFromNetwork(); case .closed: break; } } open func readDataFromNetwork() { var n: Int32 = 0; repeat { var buffer = [UInt8](repeating: 0, count: 4096); n = SSL_read(ssl, &buffer, 4096); if n > 0 { super.read(data: Data(bytes: &buffer, count: Int(n))); } } while n > 0; switch SSLStatus.from(code: n, connection: self) { case .want_write: writeDataToNetwork(completion: .none); break; case .want_read, .ok: break; case .fail: state = .closed; writeDataToNetwork(completion: .none); } } open func doHandshaking() { let result = HandshakeResult.from(code: SSL_do_handshake(ssl), connection: self); switch result { case .incomplete: state = .handshaking; writeDataToNetwork(completion: .none); case .complete: // we have completed handshake but we need to verify SSL certificate.. guard let trust = getPeerTrust() else { self.certificateValidationFailed?(nil); return; } if let cert = SecTrustGetCertificateAtIndex(trust, 0 as CFIndex) { var commonName: CFString?; SecCertificateCopyCommonName(cert, &commonName); logger.debug("received SSL certificate for common name: \(String(describing: commonName))"); } switch certificateValidation { case .default: let policy = SecPolicyCreateSSL(false, serverName as CFString?); var result = SecTrustResultType.invalid; SecTrustSetPolicies(trust, policy); _ = SecTrustEvaluateWithError(trust, nil); SecTrustGetTrustResult(trust, &result); logger.debug("certificate validation result: \(result.name)"); guard result == .proceed || result == .unspecified else { self.certificateValidationFailed?(trust); return; } case .fingerprint(let fingerprint): guard SslCertificateValidator.validateSslCertificate(domain: self.serverName ?? "", fingerprint: fingerprint, trust: trust) else { self.certificateValidationFailed?(trust); return; } case .customValidator(let validator): guard validator(trust) else { self.certificateValidationFailed?(trust); return; } } state = .active; // we need to detect ALPN protocols logger.debug("negotiated \(self.getProtocol().name) ALPN: \(self.getSelectedAlpnProtocol() ?? "nil")"); readDataFromNetwork(); writeDataToNetwork(completion: .none); encryptWaiting(); case .failed: state = .closed; writeDataToNetwork(completion: .none); } } open func getPeerCertificate() -> SSLCertificate? { guard let ptr: OpaquePointer = SSL_get_peer_certificate(ssl) else { return nil; } return SSLCertificate(withOwnedReference: ptr); } open func getPeerCertificateChain() -> [SSLCertificate]? { guard let chainPtr = SSL_get_peer_cert_chain(ssl) else { return nil } var chains: [SSLCertificate] = []; var ptr: OpaquePointer?; repeat { ptr = sk_X509_shift(chainPtr) if ptr != nil { chains.append(SSLCertificate(withOwnedReference: ptr!)); } } while ptr != nil; return chains; } open func getPeerTrust() -> SecTrust? { guard let chain = getPeerCertificateChain(), let cert = chain.first?.secCertificate() else { return nil; } var commonName: CFString?; SecCertificateCopyCommonName(cert, &commonName); var trust: SecTrust?; guard SecTrustCreateWithCertificates(chain.compactMap({ $0.secCertificate() }) as CFArray, SecPolicyCreateBasicX509(), &trust) == errSecSuccess else { return nil; } return trust; } open func getSelectedAlpnProtocol() -> String? { var name = UnsafePointer(bitPattern: 0); var len: UInt32 = 0; SSL_get0_alpn_selected(ssl, &name, &len); guard len > 0 else { return nil; } return String(decoding: UnsafeBufferPointer(start: name, count: Int(len)), as: UTF8.self); } open func getProtocol() -> SSLProtocol { guard let session = SSL_get_session(ssl) else { return .unknown; } return SSLProtocol.from(protocolId: SSL_SESSION_get_protocol_version(session)); } private struct Entry { public let data: Data; public let completion: WriteCompletion; } private var awaitingEncryption = Queue(); open func encryptWaiting() { guard !awaitingEncryption.isEmpty else { writeDataToNetwork(completion: .none); return; } var shouldContinue = true; while shouldContinue, let entry = awaitingEncryption.poll() { let n = entry.data.withUnsafeBytes({ bufPtr in return SSL_write(ssl, bufPtr.baseAddress!, Int32(entry.data.count)); }) switch SSLStatus.from(code: n, connection: self) { case .want_write, .ok: writeDataToNetwork(completion: entry.completion); case .want_read: shouldContinue = true; break; case .fail: shouldContinue = false; state = .closed; writeDataToNetwork(completion: .none); entry.completion.completed(result: .failure(XMPPError.undefined_condition)); } } } open func encrypt(data: Data, completion: WriteCompletion) { if !data.isEmpty { awaitingEncryption.offer(.init(data: data, completion: completion)); } switch state { case .handshaking: doHandshaking(); case .active: encryptWaiting(); case .closed: break; } } open func writeDataToNetwork(completion: WriteCompletion) { var n: Int32 = 0; repeat { let waiting = BIO_ctrl_pending(writeBio); var buffer = [UInt8](repeating: 0, count: waiting); n = BIO_read(writeBio, &buffer, Int32(waiting)); if n > 0 { super.write(data: Data(bytes: &buffer, count: Int(n)), completion: completion); } } while n > 0; } } public struct SSLProcessorProvider: NetworkProcessorProvider { public let providedFeatures: [ConnectorFeature] = [.TLS]; public let supportedTlsVersions: ClosedRange; public init(supportedTlsVersions: ClosedRange = TLSVersion.TLSv1_2...TLSVersion.TLSv1_3) { self.supportedTlsVersions = supportedTlsVersions; } public func supply() -> SocketConnector.NetworkProcessor { let context = SSLContext(supportedTlsVersions: supportedTlsVersions)!; return context.createConnection()!; } } ================================================ FILE: SiskinIM/AppDelegate.swift ================================================ // // AppDelegate.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import UserNotifications import Martin import Shared import WebRTC import BackgroundTasks import Combine import TigaseLogging import Intents @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { fileprivate let backgroundRefreshTaskIdentifier = "org.tigase.messenger.mobile.refresh"; var window: UIWindow? let notificationCenterDelegate = NotificationCenterDelegate(); private var cancellables: Set = []; private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "main"); func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { BGTaskScheduler.shared.register(forTaskWithIdentifier: backgroundRefreshTaskIdentifier, using: nil) { (task) in self.handleAppRefresh(task: task as! BGAppRefreshTask); } // RTCInitFieldTrialDictionary([:]); RTCInitializeSSL(); //RTCSetupInternalTracer(); AccountSettings.initialize(); switch Settings.appearance { case .light: for window in application.windows { window.overrideUserInterfaceStyle = .light; } case .dark: for window in application.windows { window.overrideUserInterfaceStyle = .dark; } default: break; } Settings.$appearance.map({ $0.value }).receive(on: DispatchQueue.main).sink(receiveValue: { value in for window in application.windows { window.overrideUserInterfaceStyle = value; } }).store(in: &cancellables); _ = JingleManager.instance; UINavigationBar.appearance().tintColor = UIColor(named: "tintColor"); _ = NotificationManager.instance; XmppService.instance.initialize(); UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (granted, error) in if let error = error { self.logger.debug("error while requesting notifications authorization: \(error)"); } else { DispatchQueue.main.async { application.registerForRemoteNotifications(); } } } UNUserNotificationCenter.current().delegate = self.notificationCenterDelegate; let categories = [ UNNotificationCategory(identifier: "MESSAGE", actions: [], intentIdentifiers: [], hiddenPreviewsBodyPlaceholder: NSLocalizedString("New message", comment: "notification of incoming message on locked screen"), options: [.customDismissAction]) ]; UNUserNotificationCenter.current().setNotificationCategories(Set(categories)); CallManager.initializeCallManager(); NotificationCenter.default.addObserver(self, selector: #selector(AppDelegate.serverCertificateError), name: XmppService.SERVER_CERTIFICATE_ERROR, object: nil); NotificationCenter.default.addObserver(self, selector: #selector(AppDelegate.pushNotificationRegistrationFailed), name: Notification.Name("pushNotificationsRegistrationFailed"), object: nil); AccountManager.accountEventsPublisher.sink(receiveValue: { [weak self] action in guard case .removed(_) = action else { return; } if AccountManager.getAccounts().isEmpty { self?.window?.rootViewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "SetupViewController"); } }).store(in: &cancellables); (self.window?.rootViewController as? UISplitViewController)?.preferredDisplayMode = .allVisible; if AccountManager.getAccounts().isEmpty { self.window?.rootViewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "SetupViewController"); } return true } func showSetup(value: Bool) { DispatchQueue.main.async { if let rootView = self.window?.rootViewController { let normalMode: Bool = rootView is UISplitViewController; let expNormalMode = !value; if normalMode != expNormalMode { if expNormalMode { (UIApplication.shared.delegate as? AppDelegate)?.hideSetupGuide(); } else { self.window?.rootViewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "SetupViewController"); } } } } } func applicationWillResignActive(_ application: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. } private var backgroundTaskId = UIBackgroundTaskIdentifier.invalid; func applicationDidEnterBackground(_ application: UIApplication) { // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. XmppService.instance.updateApplicationState(.inactive); initiateBackgroundTask(); } func initiateBackgroundTask() { guard XmppService.instance.applicationState != .active else { return; } let application = UIApplication.shared; backgroundTaskId = application.beginBackgroundTask { self.logger.debug("keep online on away background task \(self.backgroundTaskId) expired"); self.applicationKeepOnlineOnAwayFinished(application); } if backgroundTaskId == .invalid { logger.debug("failed to start keep online background task"); XmppService.instance.updateApplicationState(.suspended); } else { let taskId = backgroundTaskId; logger.debug("keep online task \(taskId) started"); } } func applicationKeepOnlineOnAwayFinished(_ application: UIApplication) { let taskId = backgroundTaskId; guard taskId != .invalid else { return; } backgroundTaskId = .invalid; logger.debug("keep online task \(taskId) expired"); XmppService.instance.updateApplicationState(.suspended); XmppService.instance.backgroundTaskFinished(); logger.debug("keep online calling end background task \(taskId)"); scheduleAppRefresh(); logger.debug("keep online task \(taskId) ended"); application.endBackgroundTask(taskId); } func applicationWillEnterForeground(_ application: UIApplication) { CallManager.initializeCallManager(); // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. UNUserNotificationCenter.current().getDeliveredNotifications { (notifications) in let toDiscard = notifications.filter({(notification) in switch NotificationCategory.from(identifier: notification.request.content.categoryIdentifier) { case .UNSENT_MESSAGES: return true; case .MESSAGE: return notification.request.content.userInfo["sender"] as? String == nil; default: return false; } }).map({ (notiication) -> String in return notiication.request.identifier; }); guard !toDiscard.isEmpty else { return; } UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: toDiscard) NotificationManager.instance.updateApplicationIconBadgeNumber(completionHandler: nil); } } func applicationDidBecomeActive(_ application: UIApplication) { BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: backgroundRefreshTaskIdentifier); // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. // TODO: XmppService::initialize() call in application:willFinishLaunchingWithOptions results in starting a connections while it may not always be desired if ie. app is relauched in the background due to crash // Shouldn't it wait for reconnection till it becomes active? or background refresh task is called? XmppService.instance.updateApplicationState(.active); applicationKeepOnlineOnAwayFinished(application); NotificationManager.instance.updateApplicationIconBadgeNumber(completionHandler: nil); } func applicationWillTerminate(_ application: UIApplication) { // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. RTCShutdownInternalTracer(); RTCCleanupSSL(); logger.debug("application terminated!") } func application(_ application: UIApplication, continue activity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { guard let intent = activity.interaction?.intent as? INSendMessageIntent else { return false; } guard let account = BareJID(intent.sender?.personHandle?.value), AccountManager.getAccount(for: account)?.active ?? false else { return false; } guard let recipient = BareJID(intent.recipients?.first?.personHandle?.value) else { return false; } guard let xmppClient = XmppService.instance.getClient(for: account) else { return false; } var chatToOpen: Chat?; switch DBChatStore.instance.createChat(for: xmppClient, with: recipient) { case .created(let chat): chatToOpen = chat; case .found(let chat): chatToOpen = chat; case .none: return false; } guard let destination = self.window?.rootViewController?.storyboard?.instantiateViewController(withIdentifier: "ChatViewNavigationController") as? UINavigationController else { return false; } let chatController = destination.children[0] as! ChatViewController; chatController.hidesBottomBarWhenPushed = true; chatController.conversation = chatToOpen; self.window?.rootViewController?.showDetailViewController(destination, sender: self); return true; } // func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any? { // guard let sendMessageIntent = intent as? INSendMessageIntent, sendMessageIntent.content == nil else { // return nil; // } // // return SendMessageIntentHandler(); // } // // class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling { // // func handle(intent: INSendMessageIntent, completion: @escaping (INSendMessageIntentResponse) -> Void) { // guard let account = BareJID(intent.sender?.personHandle?.value) else { // completion(.init(code: .failure, userActivity: nil)); // return; // } // guard let recipient = BareJID(intent.recipients?.first?.personHandle?.value) else { // completion(.init(code: .failure, userActivity: nil)); // return; // } // } // // } func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false; } logger.debug("got url to open: \(components)"); guard let xmppUri = XmppUri(url: url) else { return false; } logger.debug("got xmpp url with jid: \(xmppUri.jid), action: \(xmppUri.action as Any), params: \(xmppUri.dict as Any)"); if let action = xmppUri.action { self.open(xmppUri: xmppUri, action: action); return true; } else { DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Open URL", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("What do you want to do with %@?", comment: "alert body"), url.description), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("Open chat", comment: "action label"), style: .default, handler: { (action) in self.open(xmppUri: xmppUri, action: .message); })) alert.addAction(UIAlertAction(title: NSLocalizedString("Join room", comment: "action label"), style: .default, handler: { (action) in self.open(xmppUri: xmppUri, action: .join); })) alert.addAction(UIAlertAction(title: NSLocalizedString("Add contact", comment: "action label"), style: .default, handler: { (action) in self.open(xmppUri: xmppUri, action: .roster); })) alert.addAction(UIAlertAction(title: NSLocalizedString("Nothing", comment: "action label"), style: .cancel, handler: nil)); self.window?.rootViewController?.present(alert, animated: true, completion: nil); } return false; } } func open(xmppUri: XmppUri, action: XmppUri.Action, then: (()->Void)? = nil) { switch action { case .join: let navController = UIStoryboard(name: "MIX", bundle: nil).instantiateViewController(withIdentifier: "ChannelJoinNavigationViewController") as! UINavigationController; let joinController = navController.visibleViewController as! ChannelSelectToJoinViewController; joinController.joinConversation = (xmppUri.jid.bareJid, xmppUri.dict?["password"]); joinController.hidesBottomBarWhenPushed = true; navController.modalPresentationStyle = .formSheet; window?.rootViewController?.dismiss(animated: true, completion: { self.window?.rootViewController?.present(navController, animated: true, completion: nil); }) case .message: let alert = UIAlertController(title: NSLocalizedString("Start chatting", comment: "alert title"), message: NSLocalizedString("Select account to open chat from", comment: "alert body"), preferredStyle: .alert); let accounts = XmppService.instance.clients.values.map({ (client) -> BareJID in return client.userBareJid; }).sorted { (a1, a2) -> Bool in return a1.stringValue.compare(a2.stringValue) == .orderedAscending; } let openChatFn: (BareJID)->Void = { (account) in guard let xmppClient = XmppService.instance.getClient(for: account) else { return; } var chatToOpen: Chat?; switch DBChatStore.instance.createChat(for: xmppClient, with: xmppUri.jid.bareJid) { case .created(let chat): chatToOpen = chat; case .found(let chat): chatToOpen = chat; case .none: return; } guard let destination = self.window?.rootViewController?.storyboard?.instantiateViewController(withIdentifier: "ChatViewNavigationController") as? UINavigationController else { return; } let chatController = destination.children[0] as! ChatViewController; chatController.hidesBottomBarWhenPushed = true; chatController.conversation = chatToOpen; self.window?.rootViewController?.showDetailViewController(destination, sender: self); } if accounts.count == 1 { openChatFn(accounts.first!); } else { accounts.forEach({ account in alert.addAction(UIAlertAction(title: account.stringValue, style: .default, handler: { (action) in openChatFn(account); })); }) self.window?.rootViewController?.present(alert, animated: true, completion: nil); } case .roster: if let dict = xmppUri.dict, let ibr = dict["ibr"], ibr == "y" { guard !AccountManager.getAccounts().isEmpty else { self.open(xmppUri: XmppUri(jid: JID(xmppUri.jid.domain), action: .register, dict: dict), action: .register, then: { DispatchQueue.main.async { self.open(xmppUri: xmppUri, action: action); } }); return; } } guard let navigationController = self.window?.rootViewController?.storyboard?.instantiateViewController(withIdentifier: "RosterItemEditNavigationController") as? UINavigationController else { return; } let itemEditController = navigationController.visibleViewController as? RosterItemEditViewController; itemEditController?.hidesBottomBarWhenPushed = true; navigationController.modalPresentationStyle = .formSheet; self.window?.rootViewController?.present(navigationController, animated: true, completion: { itemEditController?.account = nil; itemEditController?.jid = xmppUri.jid; itemEditController?.jidTextField.text = xmppUri.jid.stringValue; itemEditController?.nameTextField.text = xmppUri.dict?["name"]; itemEditController?.preauth = xmppUri.dict?["preauth"]; }); case .register: let alert = UIAlertController(title: NSLocalizedString("Registering account", comment: "alert title"), message: xmppUri.jid.localPart == nil ? String.localizedStringWithFormat(NSLocalizedString("Do you wish to register a new account at %@?", comment: "alert body"), xmppUri.jid.domain!) : String.localizedStringWithFormat(NSLocalizedString("Do you wish to register a new account %@?", comment: "alert body"), xmppUri.jid.stringValue), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("Yes", comment: "button label"), style: .default, handler: { action in let registerAccountController = RegisterAccountController.instantiate(fromAppStoryboard: .Account); registerAccountController.hidesBottomBarWhenPushed = true; registerAccountController.account = xmppUri.jid.bareJid; registerAccountController.preauth = xmppUri.dict?["preauth"]; registerAccountController.onAccountAdded = then; self.window?.rootViewController?.showDetailViewController(UINavigationController(rootViewController: registerAccountController), sender: self); })); alert.addAction(UIAlertAction(title: NSLocalizedString("No", comment: "button label"), style: .cancel, handler: nil)); self.window?.rootViewController?.present(alert, animated: true, completion: nil); } } func scheduleAppRefresh() { let request = BGAppRefreshTaskRequest(identifier: backgroundRefreshTaskIdentifier); request.earliestBeginDate = Date(timeIntervalSinceNow: 3500); do { try BGTaskScheduler.shared.submit(request); } catch { logger.error("Could not schedule app refresh: \(error)") } } var backgroundFetchInProgress = false; func handleAppRefresh(task: BGAppRefreshTask) { guard DispatchQueue.main.sync(execute: { if self.backgroundFetchInProgress { return false; } backgroundFetchInProgress = true; return true; }) else { task.setTaskCompleted(success: true); return; } self.scheduleAppRefresh(); let fetchStart = Date(); logger.debug("starting fetching"); XmppService.instance.preformFetch(completionHandler: {(result) in let fetchEnd = Date(); let time = fetchEnd.timeIntervalSince(fetchStart); self.logger.debug("fetched data in \(time) seconds with result = \(result)"); self.backgroundFetchInProgress = false; task.setTaskCompleted(success: result != .failed); }); task.expirationHandler = { self.logger.debug("task expiration reached, start"); DispatchQueue.main.sync { self.backgroundFetchInProgress = false; } XmppService.instance.performFetchExpired(); self.logger.debug("task expiration reached, end"); } } static func isChatVisible(account acc: String?, with j: String?) -> Bool { guard let account = acc, let jid = j else { return false; } guard let baseChatController = AppDelegate.getChatController(visible: true) else { return false; } return (baseChatController.conversation.account == BareJID(account)) && (baseChatController.conversation.jid == BareJID(jid)); } static func getChatController(visible: Bool) -> BaseChatViewController? { var topController = UIApplication.shared.windows.first(where:{ $0.isKeyWindow })?.rootViewController; while (topController?.presentedViewController != nil) { topController = topController?.presentedViewController; } guard let splitViewController = topController as? UISplitViewController else { return nil; } guard let navigationController = navigationController(fromSplitViewController: splitViewController) else { return nil; } if visible { return navigationController.viewControllers.last as? BaseChatViewController; } else { for controller in navigationController.viewControllers.reversed() { if let baseChatViewController = controller as? BaseChatViewController { return baseChatViewController; } } return nil; } // } else { // return selectedTabController as? BaseChatViewController; // } } private static func navigationController(fromSplitViewController splitViewController: UISplitViewController) -> UINavigationController? { if splitViewController.isCollapsed { return splitViewController.viewControllers.first(where: { $0 is UITabBarController }).map({ $0 as! UITabBarController })?.selectedViewController as? UINavigationController; } else { return splitViewController.viewControllers.first(where: { !($0 is UITabBarController) }) as? UINavigationController; } } func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let tokenString = deviceToken.reduce("", {$0 + String(format: "%02X", $1)}); logger.debug("registered for remote notifications, got device token: \(deviceToken.map({ String(format: "%02x", $0 )}).joined(), privacy: .public)"); PushEventHandler.instance.deviceId = tokenString; } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { logger.error("failed to register for remote notifications: \(error, privacy: .public)"); PushEventHandler.instance.deviceId = nil; Settings.enablePush = false; // Settings.DeviceToken.setValue(nil); } func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any]) { logger.debug("Push notification received: \(userInfo)"); } func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { logger.debug("Push notification received with fetch request: \(userInfo)"); //let fetchStart = Date(); if let account = JID(userInfo[AnyHashable("account")] as? String) { let sender = JID(userInfo[AnyHashable("sender")] as? String); let body = userInfo[AnyHashable("body")] as? String; if let unreadMessages = userInfo[AnyHashable("unread-messages")] as? Int, unreadMessages == 0 && sender == nil && body == nil { let state = XmppService.instance.getClient(for: account.bareJid)?.state; logger.debug("unread messages retrieved, client state = \(state, privacy: .public)"); if state != .connected() { dismissNewMessageNotifications(for: account) { completionHandler(.newData); } return; } } else if body != nil { // FIXME: not sure about this `date`!! NotificationManager.instance.notifyNewMessage(account: account.bareJid, sender: sender?.bareJid, nickname: userInfo[AnyHashable("nickname")] as? String, body: body!, date: Date()); } else { if let encryped = userInfo["encrypted"] as? String, let ivStr = userInfo["iv"] as? String, let key = NotificationEncryptionKeys.key(for: account.bareJid), let data = Data(base64Encoded: encryped), let iv = Data(base64Encoded: ivStr) { logger.debug("got encrypted push with known key"); let cipher = Cipher.AES_GCM(); var decoded = Data(); if cipher.decrypt(iv: iv, key: key, encoded: data, auth: nil, output: &decoded) { logger.debug("got decrypted data: \(String(data: decoded, encoding: .utf8) as Any)"); if let payload = try? JSONDecoder().decode(Payload.self, from: decoded) { logger.debug("decoded payload successfully!"); // we require `media` to be present (even empty) in incoming push for jingle session initiation, // so we can assume that if `media` is `nil` then this is a push for call termination if let sid = payload.sid, payload.media == nil { guard CallManager.isAvailable else { completionHandler(.newData); return; } CallManager.instance?.endCall(on: account.bareJid, with: payload.sender.bareJid, sid: sid, completionHandler: { self.logger.debug("ended call"); completionHandler(.newData); }) return; } } } } } } completionHandler(.newData); } func dismissNewMessageNotifications(for account: JID, completionHandler: (()-> Void)?) { UNUserNotificationCenter.current().getDeliveredNotifications { (notifications) in let toRemove = notifications.filter({ (notification) in switch NotificationCategory.from(identifier: notification.request.content.categoryIdentifier) { case .MESSAGE: return (notification.request.content.userInfo["account"] as? String) == account.stringValue; default: return false; } }).map({ (notification) in notification.request.identifier }); UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: toRemove); NotificationManager.instance.updateApplicationIconBadgeNumber(completionHandler: completionHandler); } } @objc func pushNotificationRegistrationFailed(_ notification: NSNotification) { let account = notification.userInfo?["account"] as? BareJID; let errorCondition = (notification.userInfo?["errorCondition"] as? ErrorCondition) ?? ErrorCondition.internal_server_error; let content = UNMutableNotificationContent(); switch errorCondition { case .remote_server_timeout: content.body = NSLocalizedString("It was not possible to contact push notification component.\nTry again later.", comment: "push notifications registration failure message") case .remote_server_not_found: content.body = NSLocalizedString("It was not possible to contact push notification component.", comment: "push notifications registration failure message") case .service_unavailable: content.body = NSLocalizedString("Push notifications not available", comment: "push notifications registration failure message") default: content.body = String.localizedStringWithFormat(NSLocalizedString("It was not possible to contact push notification component: %@", comment: "push notifications registration failure message"), errorCondition.rawValue); } content.threadIdentifier = "account=" + account!.stringValue; content.categoryIdentifier = "ERROR"; content.userInfo = ["account": account!.stringValue]; UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)); } @objc func serverCertificateError(_ notification: NSNotification) { guard let certInfo = notification.userInfo else { return; } let account = BareJID(certInfo["account"] as! String); let content = UNMutableNotificationContent(); content.body = String.localizedStringWithFormat(NSLocalizedString("Connection to server %@ failed", comment: "error notification message"), account.domain); content.userInfo = certInfo; content.categoryIdentifier = "ERROR"; content.threadIdentifier = "account=" + account.stringValue; UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)); } func notifyUnsentMessages(count: Int) { let content = UNMutableNotificationContent(); content.body = String.localizedStringWithFormat(NSLocalizedString("It was not possible to send %d messages. Open the app to retry", comment: "unsent messages notification"), count); content.categoryIdentifier = "UNSENT_MESSAGES"; content.threadIdentifier = "unsent-messages"; content.sound = .default; UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)); } func hideSetupGuide() { self.window?.rootViewController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController(); (self.window?.rootViewController as? UISplitViewController)?.preferredDisplayMode = .allVisible; } struct XmppUri { let jid: JID; let action: Action?; let dict: [String: String]?; init?(url: URL?) { guard url != nil else { return nil; } guard let components = URLComponents(url: url!, resolvingAgainstBaseURL: false) else { return nil; } guard components.host == nil else { return nil; } self.jid = JID(components.path); if var pairs = components.query?.split(separator: ";").map({ (it: Substring) -> [Substring] in it.split(separator: "=") }) { if let first = pairs.first, first.count == 1 { action = Action(rawValue: String(first.first!)); pairs = Array(pairs.dropFirst()); } else { action = nil; } var dict: [String: String] = [:]; for pair in pairs { dict[String(pair[0])] = pair.count == 1 ? "" : String(pair[1]); } self.dict = dict; } else { self.action = nil; self.dict = nil; } } init(jid: JID, action: Action?, dict: [String:String]?) { self.jid = jid; self.action = action; self.dict = dict; } func toURL() -> URL? { var parts = URLComponents(); parts.scheme = "xmpp"; parts.path = jid.stringValue; if action != nil { parts.query = action!.rawValue + (dict?.map({ (k,v) -> String in ";\(k)=\(v)"}).joined() ?? ""); } else { parts.query = dict?.map({ (k,v) -> String in ";\(k)=\(v)"}).joined(); } return parts.url; } enum Action: String { case message case join case roster case register } } } ================================================ FILE: SiskinIM/Assets.xcassets/AppIcon-Simple.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, { "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, { "idiom" : "iphone", "scale" : "1x", "size" : "29x29" }, { "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, { "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, { "filename" : "80.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, { "filename" : "120 1.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, { "idiom" : "iphone", "scale" : "1x", "size" : "57x57" }, { "idiom" : "iphone", "scale" : "2x", "size" : "57x57" }, { "filename" : "120.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, { "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, { "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, { "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, { "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, { "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, { "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, { "idiom" : "ipad", "scale" : "1x", "size" : "50x50" }, { "idiom" : "ipad", "scale" : "2x", "size" : "50x50" }, { "filename" : "72.png", "idiom" : "ipad", "scale" : "1x", "size" : "72x72" }, { "idiom" : "ipad", "scale" : "2x", "size" : "72x72" }, { "filename" : "76.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, { "filename" : "152.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, { "filename" : "167.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, { "filename" : "1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: SiskinIM/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, { "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, { "idiom" : "iphone", "scale" : "1x", "size" : "29x29" }, { "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, { "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, { "filename" : "80.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, { "filename" : "120 1.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, { "idiom" : "iphone", "scale" : "1x", "size" : "57x57" }, { "idiom" : "iphone", "scale" : "2x", "size" : "57x57" }, { "filename" : "120.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, { "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, { "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, { "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, { "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, { "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, { "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, { "idiom" : "ipad", "scale" : "1x", "size" : "50x50" }, { "idiom" : "ipad", "scale" : "2x", "size" : "50x50" }, { "filename" : "72.png", "idiom" : "ipad", "scale" : "1x", "size" : "72x72" }, { "idiom" : "ipad", "scale" : "2x", "size" : "72x72" }, { "filename" : "76.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, { "filename" : "152.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, { "filename" : "167.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, { "filename" : "1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: SiskinIM/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: SiskinIM/Assets.xcassets/appLogo.imageset/Contents.json ================================================ { "images" : [ { "filename" : "1024.png", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: SiskinIM/Assets.xcassets/appearance/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: SiskinIM/Assets.xcassets/appearance/chatMessageText.colorset/Contents.json ================================================ { "info" : { "version" : 1, "author" : "xcode" }, "colors" : [ { "idiom" : "universal", "color" : { "color-space" : "gray-gamma-22", "components" : { "white" : "0.150", "alpha" : "1.000" } } }, { "idiom" : "universal", "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "gray-gamma-22", "components" : { "white" : "0.850", "alpha" : "1.000" } } } ] } ================================================ FILE: SiskinIM/Assets.xcassets/appearance/chatslistBackground.colorset/Contents.json ================================================ { "info" : { "version" : 1, "author" : "xcode" }, "colors" : [ { "idiom" : "universal", "color" : { "color-space" : "display-p3", "components" : { "red" : "0x5E", "alpha" : "1.000", "blue" : "0x8A", "green" : "0x5E" } } }, { "idiom" : "universal", "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "display-p3", "components" : { "red" : "0x25", "alpha" : "1.000", "blue" : "0x38", "green" : "0x25" } } } ] } ================================================ FILE: SiskinIM/Assets.xcassets/appearance/chatslistItemSecondaryLabel.colorset/Contents.json ================================================ { "colors" : [ { "color" : { "color-space" : "gray-gamma-22", "components" : { "alpha" : "1.000", "white" : "0.823" } }, "idiom" : "universal" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "gray-gamma-22", "components" : { "alpha" : "1.000", "white" : "0.667" } }, "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: SiskinIM/Assets.xcassets/appearance/chatslistSemiBackground.colorset/Contents.json ================================================ { "colors" : [ { "color" : { "color-space" : "display-p3", "components" : { "alpha" : "0.500", "blue" : "0.541", "green" : "0.369", "red" : "0.369" } }, "idiom" : "universal" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "display-p3", "components" : { "alpha" : "0.500", "blue" : "0.220", "green" : "0.145", "red" : "0.145" } }, "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: SiskinIM/Assets.xcassets/appearance/tintColor.colorset/Contents.json ================================================ { "colors" : [ { "color" : { "color-space" : "display-p3", "components" : { "alpha" : "1.000", "blue" : "0x7C", "green" : "0x54", "red" : "0x54" } }, "idiom" : "universal" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "display-p3", "components" : { "alpha" : "1.000", "blue" : "1.000", "green" : "0.722", "red" : "0.722" } }, "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: SiskinIM/Assets.xcassets/audioCall.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "filename" : "icons8-call-60.png", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: SiskinIM/Assets.xcassets/defaultAvatar.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "avatar-light.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "avatar-dark.png", "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "2x", "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ] }, { "idiom" : "universal", "scale" : "3x" }, { "idiom" : "universal", "scale" : "3x", "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ] } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: SiskinIM/Assets.xcassets/defaultGroupchatAvatar.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "groupchat-avatar-light.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "groupchat-avatar-dark.png", "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "2x", "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ] }, { "idiom" : "universal", "scale" : "3x" }, { "idiom" : "universal", "scale" : "3x", "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ] } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: SiskinIM/Assets.xcassets/endCall.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "filename" : "icons8-end-call-60.png", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" }, "properties" : { "template-rendering-intent" : "template", "auto-scaling" : "auto" } } ================================================ FILE: SiskinIM/Assets.xcassets/first.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "first.pdf" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: SiskinIM/Assets.xcassets/message.fill.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "filename" : "round_chat_bubble_black_24pt_2x.png", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: SiskinIM/Assets.xcassets/messageArchiving.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "icons8-communication.pdf" } ], "info" : { "version" : 1, "author" : "xcode" }, "properties" : { "template-rendering-intent" : "template", "preserves-vector-representation" : true } } ================================================ FILE: SiskinIM/Assets.xcassets/mute.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "filename" : "icons8-block-microphone-60.png", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: SiskinIM/Assets.xcassets/participants.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "filename" : "icons8-user-groups-50.png", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: SiskinIM/Assets.xcassets/person.crop.circle.fill.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "filename" : "round_account_circle_black_24pt_2x.png", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: SiskinIM/Assets.xcassets/pushNotifications.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "icons8-push-notifications.pdf" } ], "info" : { "version" : 1, "author" : "xcode" }, "properties" : { "template-rendering-intent" : "template", "preserves-vector-representation" : true } } ================================================ FILE: SiskinIM/Assets.xcassets/qrCodeBackground.colorset/Contents.json ================================================ { "colors" : [ { "color" : { "platform" : "ios", "reference" : "systemBackgroundColor" }, "idiom" : "universal" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "platform" : "ios", "reference" : "systemBackgroundColor" }, "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "localizable" : true } } ================================================ FILE: SiskinIM/Assets.xcassets/qrCodeForeground.colorset/Contents.json ================================================ { "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0x99", "green" : "0x67", "red" : "0x30" } }, "idiom" : "universal" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0xE4", "green" : "0xD0", "red" : "0xAB" } }, "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: SiskinIM/Assets.xcassets/second.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "second.pdf" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: SiskinIM/Assets.xcassets/switchCamera.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "filename" : "icons8-switch-camera-filled-50.png", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: SiskinIM/Assets.xcassets/tigaseLogo.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "filename" : "tigase_logo_1024x1024.png", "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: SiskinIM/Assets.xcassets/videoCall.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "filename" : "icons8-video-call-60.png", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: SiskinIM/Info.plist ================================================ BGTaskSchedulerPermittedIdentifiers org.tigase.messenger.mobile.refresh CFBundleDevelopmentRegion en CFBundleDisplayName Siskin IM CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName Siskin IM CFBundlePackageType APPL CFBundleShortVersionString $(MARKETING_VERSION) CFBundleSignature ???? CFBundleURLTypes CFBundleTypeRole Editor CFBundleURLName com.tigase.messenger.mobile CFBundleURLSchemes xmpp CFBundleVersion $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS NSAppTransportSecurity NSAllowsArbitraryLoads NSCameraUsageDescription Uses camera to capture photo for avatar and for VoIP calls NSLocationWhenInUseUsageDescription Uses location to share it with your contacts on your requests NSMicrophoneUsageDescription Uses microphone for VoIP calls NSPhotoLibraryAddUsageDescription Used to save attachements from conversations NSPhotoLibraryUsageDescription Used to select avatar NSUserActivityTypes INSendMessageIntent INStartCallIntent UIBackgroundModes fetch remote-notification voip UILaunchStoryboardName LaunchScreen UIMainStoryboardFile Main UIRequiredDeviceCapabilities armv7 UIStatusBarTintParameters UINavigationBar Style UIBarStyleDefault Translucent UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UTExportedTypeDeclarations ================================================ FILE: SiskinIM/SiskinIM-Bridging-Header.h ================================================ // // SiskinIM-Bridging-Header.h // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // #ifndef SiskinIM_Bridging_h #define SiskinIM_Bridging_h #import "sqlite3.h" typedef void (*sqlite3_destructor_type)(void*); #define SQLITE_STATIC ((sqlite3_destructor_type)0) #define SQLITE_TRANSIENT ((sqlite3_destructor_type)-1) #endif /* SiskinIM_Bridging_h */ ================================================ FILE: SiskinIM/bookmarks/BookmarkItem.swift ================================================ // // BookmarkItem.swift // // Siskin IM // Copyright (C) 2022 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin public struct BookmarkItem { public let account: BareJID; public let item: Bookmarks.Conference; public var jid: JID { return item.jid; } public var name: String { return item.name ?? item.jid.localPart ?? item.jid.stringValue; } public var nickname: String? { return item.nick; } public var password: String? { return item.password; } public var autojoin: Bool { return item.autojoin; } } ================================================ FILE: SiskinIM/bookmarks/BookmarkViewCell.swift ================================================ // // BookmarkViewCell.swift // // Siskin IM // Copyright (C) 2022 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import UIKit import Martin import Combine public class BookmarkViewCell: UITableViewCell { @IBOutlet var avatarView: AvatarView!; @IBOutlet var nameLabel: UILabel!; @IBOutlet var jidLabel: UILabel!; private var avatar: Avatar? { didSet { avatar?.avatarPublisher.map({ $0 ?? AvatarManager.instance.defaultGroupchatAvatar }).receive(on: DispatchQueue.main).assign(to: \.avatar, on: avatarView).store(in: &cancellables); } } private var cancellables: Set = []; var bookmark: BookmarkItem? { didSet { cancellables.removeAll(); if let bookmark = self.bookmark { avatar = AvatarManager.instance.avatarPublisher(for: .init(account: bookmark.account, jid: bookmark.jid.bareJid, mucNickname: nil)); nameLabel.text = bookmark.name; jidLabel.text = bookmark.jid.stringValue; } else { avatar = nil; } } } } ================================================ FILE: SiskinIM/bookmarks/BookmarksController.swift ================================================ // // BookmarksController.swift // // Siskin IM // Copyright (C) 2022 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine public class BookmarksController: UITableViewController { private var items: [BookmarkItem] = [] { didSet { self.tableView.reloadData(); } } private var clientCancellable: AnyCancellable?; private var cancellables: Set = []; public override func viewDidLoad() { super.viewDidLoad(); setColors(); } public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); clientCancellable = XmppService.instance.$clients.receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] clients in guard let that = self else { return; } that.cancellables.removeAll() that.items = []; for client in clients.values { let account = client.userBareJid; client.module(.pepBookmarks).$currentBookmarks.receive(on: DispatchQueue.main).sink(receiveValue: { bookmarks in guard let that = self else { return; } that.items = (that.items.filter({ $0.account != account }) + bookmarks.items.compactMap({ $0 as? Bookmarks.Conference }).map({ BookmarkItem(account: account, item: $0) })).sorted(by: { b1, b2 in b1.name < b2.name }); }).store(in: &that.cancellables) } }); animate(); } public override func viewDidDisappear(_ animated: Bool) { clientCancellable = nil; cancellables.removeAll(); items.removeAll(); super.viewDidDisappear(animated); } public override func numberOfSections(in tableView: UITableView) -> Int { return 1; } public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return items.count; } public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "bookmarkCell", for: indexPath) as! BookmarkViewCell; cell.bookmark = items[indexPath.row]; return cell; } public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true); guard items.count > indexPath.row else { return; } let item = items[indexPath.row]; join(bookmark: item); } public override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard items.count > indexPath.row else { return nil; } let item = items[indexPath.row]; return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { suggestedActions -> UIMenu? in var items = [ UIAction(title: NSLocalizedString("Join", comment: "button label"), image: UIImage(systemName: "person.badge.plus"), handler: { action in self.join(bookmark: item); }) ]; if (item.autojoin) { items.append(UIAction(title: NSLocalizedString("Disable autojoin", comment: "button label"), image: UIImage(systemName: "star.slash"), handler: { action in self.pepBookmarksModule(for: item.account)?.setConferenceAutojoin(false, for: item.jid); })); } else { items.append(UIAction(title: NSLocalizedString("Enable autojoin", comment: "button label"), image: UIImage(systemName: "star"), handler: { action in self.pepBookmarksModule(for: item.account)?.setConferenceAutojoin(true, for: item.jid); })); } items.append(UIAction(title: NSLocalizedString("Delete", comment: "button label"), image: UIImage(systemName: "trash"), attributes: .destructive, handler: { action in self.pepBookmarksModule(for: item.account)?.remove(bookmark: item.item); })); return UIMenu(title: "", children: items); }; } private func pepBookmarksModule(for account: BareJID) -> PEPBookmarksModule? { return XmppService.instance.getClient(for: account)?.module(.pepBookmarks); } private func join(bookmark item: BookmarkItem) { guard let conversation = DBChatStore.instance.conversation(for: item.account, with: item.jid.bareJid) else { guard let client = XmppService.instance.getClient(for: item.account), client.isConnected else { return; } let joinController = UIStoryboard(name: "MIX", bundle: nil).instantiateViewController(withIdentifier: "ChannelJoinViewController") as! ChannelJoinViewController; joinController.fromBookmark = true; joinController.client = client; joinController.channelJid = item.jid.bareJid; joinController.name = item.name; joinController.componentType = .muc; joinController.password = item.password; joinController.nickname = item.nickname; joinController.onConversationJoined = { conversation in self.open(conversation: conversation); } joinController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: joinController, action: #selector(ChannelJoinViewController.cancelClicked(_:))); joinController.hidesBottomBarWhenPushed = true; let navController = UINavigationController(rootViewController: joinController); navController.modalPresentationStyle = .formSheet; self.present(navController, animated: true, completion: nil); return; } self.open(conversation: conversation); } private func open(conversation: Conversation) { guard conversation is Room else { return; } let controller = UIStoryboard(name: "Groupchat", bundle: nil).instantiateViewController(withIdentifier: "RoomViewNavigationController"); let destination = ((controller as? UINavigationController)?.visibleViewController ?? controller) as! BaseChatViewController; destination.conversation = conversation; destination.hidesBottomBarWhenPushed = true; self.showDetailViewController(controller, sender: self); } private func animate() { guard let coordinator = self.transitionCoordinator else { return; } coordinator.animate(alongsideTransition: { [weak self] context in self?.setColors(); }, completion: nil); } private func setColors() { let appearance = UINavigationBarAppearance(); appearance.configureWithDefaultBackground(); appearance.backgroundColor = UIColor(named: "chatslistSemiBackground"); appearance.backgroundEffect = UIBlurEffect(style: .systemUltraThinMaterialDark); navigationController?.navigationBar.standardAppearance = appearance; navigationController?.navigationBar.scrollEdgeAppearance = appearance; navigationController?.navigationBar.barTintColor = UIColor(named: "chatslistBackground")?.withAlphaComponent(0.2); navigationController?.navigationBar.tintColor = UIColor.white; } } ================================================ FILE: SiskinIM/channel/ChannelBlockedUsersController.swift ================================================ // // ChannelBlockedUsersController.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class ChannelBlockedUsersController: UITableViewController { var channel: Channel!; private var jids: [BareJID] = [] { didSet { tableView.reloadData(); } } override func viewWillAppear(_ animated: Bool) { if let mixModule = channel.context?.module(.mix) { self.operationStarted(message: NSLocalizedString("Refreshing…", comment: "channel block users view operation")); mixModule.retrieveBanned(for: channel.channelJid, completionHandler: { [weak self] result in DispatchQueue.main.async { self?.operationEnded(); switch result { case .success(let blocked): self?.jids = blocked.sorted(); case .failure(_): break; } } }) } } override func numberOfSections(in tableView: UITableView) -> Int { return 1; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return jids.count; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let jid = jids[indexPath.row]; let cell = tableView.dequeueReusableCell(withIdentifier: "ChannelBlockedCellView", for: indexPath); cell.textLabel?.text = jid.stringValue; return cell; } override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { let jid = jids[indexPath.row]; return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { menu -> UIMenu? in let unblock = UIAction(title: NSLocalizedString("Unblock", comment: "action"), image: UIImage(systemName: "trash"), handler: { action in if let mixModule = self.channel.context?.module(.mix) { self.operationStarted(message: NSLocalizedString("Updating…", comment: "channel block users view operation")); mixModule.denyAccess(to: self.channel.channelJid, for: jid, value: false, completionHandler: { [weak self] result in DispatchQueue.main.async { switch result { case .success(_): self?.jids = self?.jids.filter { $0 != jid } ?? []; case .failure(_): break; } self?.operationEnded(); } }) } }); return UIMenu(title: "", children: [unblock]); }) } func operationStarted(message: String) { self.tableView.refreshControl = UIRefreshControl(); self.tableView.refreshControl?.attributedTitle = NSAttributedString(string: message); self.tableView.refreshControl?.isHidden = false; self.tableView.refreshControl?.layoutIfNeeded(); self.tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - self.tableView.refreshControl!.frame.height), animated: true) self.tableView.refreshControl?.beginRefreshing(); } func operationEnded() { self.tableView.refreshControl?.endRefreshing(); self.tableView.refreshControl = nil; } } ================================================ FILE: SiskinIM/channel/ChannelCreateViewController.swift ================================================ // // ChannelCreateViewController.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class ChannelCreateViewController: UITableViewController, ChannelSelectAccountAndComponentControllerDelgate { @IBOutlet var joinButton: UIBarButtonItem!; @IBOutlet var statusView: ChannelJoinStatusView!; @IBOutlet var channelNameField: UITextField!; @IBOutlet var channelIdField: UITextField!; var client: XMPPClient? { didSet { statusView.account = client?.userBareJid; needRefresh = true; } } var domain: String? { didSet { statusView.server = domain; needRefresh = true; } } var kind: ChannelKind = .adhoc; private var components: [ChannelsHelper.Component] = [] { didSet { updateJoinButtonStatus(); } } private var invitationOnly: Bool = true; private var useMix: Bool = false; private var needRefresh = false; override func viewDidLoad() { super.viewDidLoad(); } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); if client == nil { if let account = AccountManager.getActiveAccounts().first?.name { client = XmppService.instance.getClient(for: account); } } if needRefresh { self.refresh(); needRefresh = false; } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = super.tableView(tableView, cellForRowAt: indexPath); if indexPath.section == 1 { let view = UISwitch(); view.isOn = self.invitationOnly; view.addTarget(self, action: #selector(invitationOnlySwitchChanged(_:)), for: .valueChanged); cell.accessoryView = view; } if indexPath.section == 3 { let view = UISwitch(); view.isOn = self.useMix; view.addTarget(self, action: #selector(mixSwitchChanged(_:)), for: .valueChanged); cell.accessoryView = view; } return cell; } override func numberOfSections(in tableView: UITableView) -> Int { let count = super.numberOfSections(in: tableView); if components.map({ $0.type }).contains(.mix) { return count; } return count - 1; } override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { if kind == .adhoc && section == 2 { return 0.1; } return super.tableView(tableView, heightForHeaderInSection: section); } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { if kind == .adhoc && section == 2 { return nil; } return super.tableView(tableView, titleForHeaderInSection: section); } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if kind == .adhoc && section == 2 { return 0; } return super.tableView(tableView, numberOfRowsInSection: section); } override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { if kind == .adhoc && section == 2 { return 0.1; } return super.tableView(tableView, heightForFooterInSection: section); } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { if kind == .adhoc && section == 2 { return nil; } return super.tableView(tableView, titleForFooterInSection: section); } @objc func invitationOnlySwitchChanged(_ sender: UISwitch) { invitationOnly = sender.isOn; } @objc func mixSwitchChanged(_ sender: UISwitch) { useMix = sender.isOn; updateJoinButtonStatus(); } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if let destination = segue.destination as? ChannelSelectAccountAndComponentController { destination.delegate = self; } if let destination = segue.destination as? ChannelJoinViewController { destination.action = .create(isPublic: kind == .stable, invitationOnly: invitationOnly, description: nil, avatar: nil); destination.client = self.client; let component = self.components.first(where: { $0.type == (useMix ? .mix : .muc) })!; destination.channelJid = BareJID(domain: component.jid.domain); if kind == .stable { if let val = self.channelIdField.text, !val.isEmpty { destination.channelJid = BareJID(localPart: val, domain: component.jid.domain); } } destination.name = channelNameField.text!; destination.componentType = useMix ? .mix : .muc; } } func operationStarted(message: String) { self.tableView.refreshControl = UIRefreshControl(); self.tableView.refreshControl?.attributedTitle = NSAttributedString(string: message); self.tableView.refreshControl?.isHidden = false; self.tableView.refreshControl?.layoutIfNeeded(); self.tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - self.tableView.refreshControl!.frame.height), animated: true) self.tableView.refreshControl?.beginRefreshing(); } func operationEnded() { self.tableView.refreshControl?.endRefreshing(); self.tableView.refreshControl = nil; } @IBAction func cancelClicked(_ sender: Any) { self.dismiss(animated: true, completion: nil); } @IBAction func textFieldChanged(_ sender: Any) { updateJoinButtonStatus(); } private func updateJoinButtonStatus() { let name = self.channelNameField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""; let channelId = self.channelIdField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""; self.joinButton.isEnabled = (!name.isEmpty) && (kind == .adhoc || !channelId.isEmpty) && self.components.contains(where: { $0.type == (useMix ? .mix : .muc) }); } private func refresh() { guard let client = self.client else { return; } let domain = self.domain ?? client.userBareJid.domain; self.operationStarted(message: NSLocalizedString("Checking…", comment: "channel create view operation label")); ChannelsHelper.findComponents(for: client, at: domain, completionHandler: { components in DispatchQueue.main.async { self.components = components; let types = Set(components.map({ $0.type })); if types.count == 1 { switch types.first! { case .mix: self.useMix = true; case .muc: self.useMix = false; } } self.tableView.reloadData(); self.updateJoinButtonStatus(); self.operationEnded(); if components.isEmpty { DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Service unavailable", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("There is no service supporting channels for domain %@", comment: "alert message"), domain), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default)); self.present(alert, animated: true, completion: nil); } } } }) } enum ChannelKind { case stable case adhoc } } ================================================ FILE: SiskinIM/channel/ChannelEditInfoController.swift ================================================ // // ChannelEditInfoController.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine class ChannelEditInfoController: UITableViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate { @IBOutlet var avatarView: AvatarView!; @IBOutlet var nameField: UITextField!; @IBOutlet var descriptionField: UITextField!; var channel: Channel!; private var avatar: [PEPUserAvatarModule.Avatar]?; private var infoData: ChannelInfo?; private var cancellables: Set = []; override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); avatarView.contentMode = .scaleAspectFill; channel.displayNamePublisher.map({ $0 as String? }).receive(on: DispatchQueue.main).assign(to: \.text, on: nameField).store(in: &cancellables); channel.avatarPublisher.map({ $0 ?? AvatarManager.instance.defaultGroupchatAvatar }).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] avatar in self?.avatarView.set(name: nil, avatar: avatar); }).store(in: &cancellables); channel.descriptionPublisher.receive(on: DispatchQueue.main).assign(to: \.text, on: descriptionField).store(in: &cancellables); refresh(); } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if indexPath.row == 0 && indexPath.section == 0 { selectPhoto(.photoLibrary); tableView.deselectRow(at: indexPath, animated: true); } // super.tableView(tableView, didSelectRowAt: indexPath); } @IBAction func saveClicked(_ sender: Any) { guard let mixModule = channel.context?.module(.mix), let avatarModule = channel.context?.module(.pepUserAvatar) else { return; } self.operationStarted(message: NSLocalizedString("Updating…", comment: "channel edit info operation")); let group = DispatchGroup(); var error: Bool = false; let infoData = ChannelInfo(name: nameField.text, description: descriptionField.text, contact: self.infoData?.contact ?? []); if let oldData = self.infoData, oldData.name != infoData.name || oldData.description != infoData.description { group.enter(); mixModule.publishInfo(for: channel.channelJid, info: infoData, completionHandler: { [weak self] result in switch result { case .success(_): break; case .failure(let err): DispatchQueue.main.async { error = true; let alert = UIAlertController(title: NSLocalizedString("Could not update channel details", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Remote server returned an error: %@", comment: "alert body"), err.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); self?.present(alert, animated: true, completion: nil); } } group.leave(); }) } if let avatar = self.avatar { group.enter(); avatarModule.publishAvatar(at: channel.channelJid, avatar: avatar, completionHandler: { [weak self] result in switch result { case .success(_): break; case .failure(let err): DispatchQueue.main.async { error = true; let alert = UIAlertController(title: NSLocalizedString("Could not update channel details", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Remote server returned an error: %@", comment: "alert body"), err.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); self?.present(alert, animated: true, completion: nil); } } group.leave(); }) } group.notify(queue: DispatchQueue.main, execute: { [weak self] in self?.operationEnded(); guard !error else { return; } self?.dismiss(animated: true, completion: nil) }) } private func refresh() { guard let mixModule = channel.context?.module(.mix) else { return; } self.operationStarted(message: NSLocalizedString("Refreshing…", comment: "channel edit info operation")); mixModule.retrieveInfo(for: channel.channelJid, completionHandler: { [weak self] result in DispatchQueue.main.async { switch result { case .success(let info): self?.infoData = info; self?.nameField.text = info.name; self?.descriptionField.text = info.description; case .failure(_): self?.dismiss(animated: true, completion: nil); break; } self?.operationEnded(); } }) } func operationStarted(message: String) { self.tableView.refreshControl = UIRefreshControl(); self.tableView.refreshControl?.attributedTitle = NSAttributedString(string: message); self.tableView.refreshControl?.isHidden = false; self.tableView.refreshControl?.layoutIfNeeded(); self.tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - self.tableView.refreshControl!.frame.height), animated: true) self.tableView.refreshControl?.beginRefreshing(); } func operationEnded() { self.tableView.refreshControl?.endRefreshing(); self.tableView.refreshControl = nil; } private func selectPhoto(_ source: UIImagePickerController.SourceType) { let picker = UIImagePickerController(); picker.delegate = self; picker.allowsEditing = true; picker.sourceType = source; present(picker, animated: true, completion: nil); } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { guard let image = (info[UIImagePickerController.InfoKey.editedImage] as? UIImage), let pngImage = image.scaled(maxWidthOrHeight: 48), let pngData = pngImage.pngData() else { return; } avatar = [.init(data: pngData, mimeType: "image/png", width: Int(pngImage.size.width), height: Int(pngImage.size.width))]; if let jpegImage = image.scaled(maxWidthOrHeight: 256), let jpegData = jpegImage.jpegData(compressionQuality: 0.75) { if let items = avatar { avatar = [.init(data: jpegData, mimeType: "image/jpeg", width: Int(jpegImage.size.width), height: Int(jpegImage.size.height))] + items; } } picker.dismiss(animated: true, completion: nil); avatarView.contentMode = .scaleAspectFill; avatarView.image = image.scaled(maxWidthOrHeight: 256); } } ================================================ FILE: SiskinIM/channel/ChannelInviteController.swift ================================================ // // ChannelInviteController.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class ChannelInviteController: AbstractRosterViewController { var channel: Channel!; override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); } @IBAction func addClicked(_ sender: UIBarButtonItem) { guard let channel = self.channel, let mixModule = channel.context?.module(.mix) else { return; } guard let items = self.tableView.indexPathsForSelectedRows?.map({ self.roster?.item(at: $0) }).filter({ $0 != nil}).map({ $0! }), !items.isEmpty else { return; } let channelJid = channel.channelJid; let group = DispatchGroup(); self.operationStarted(message: NSLocalizedString("Sending invitations…", comment: "channel invitations view operation")); for item in items { group.enter(); mixModule.allowAccess(to: channel.channelJid, for: item.jid, completionHandler: { result in switch result { case .success(_): let body = "Invitation to channel: \(channelJid.stringValue)"; let mixInvitation = MixInvitation(inviter: channel.account, invitee: item.jid, channel: channelJid, token: nil); let message = mixModule.createInvitation(mixInvitation, message: body); message.messageDelivery = .request; let conversationKey: ConversationKey = DBChatStore.instance.conversation(for: channel.account, with: item.jid) ?? ConversationKeyItem(account: channel.account, jid: item.jid); let options = ConversationEntry.Options(recipient: .none, encryption: .none, isMarkable: false); DBChatHistoryStore.instance.appendItem(for: conversationKey, state: .outgoing(.sent), sender: .me(conversation: conversationKey), type: .invitation, timestamp: Date(), stanzaId: message.id, serverMsgId: nil, remoteMsgId: nil, data: body, appendix: ChatInvitationAppendix(mixInvitation: mixInvitation), options: options, linkPreviewAction: .none, completionHandler: nil); mixModule.write(message); case .failure(_): break; } group.leave(); }) } group.notify(queue: DispatchQueue.main, execute: { [weak self] in self?.operationEnded(); self?.navigationController?.popViewController(animated: true); }) } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let cell = tableView.cellForRow(at: indexPath); cell?.accessoryType = .checkmark; self.selectionChanged(); } override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { let cell = tableView.cellForRow(at: indexPath); cell?.accessoryType = .none; self.selectionChanged(); } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "ChannelInviteViewCell", for: indexPath); if let item = roster?.item(at: indexPath) { cell.textLabel?.text = item.displayName; cell.detailTextLabel?.text = item.jid.stringValue; (cell.imageView as? AvatarView)?.set(name: item.displayName, avatar: AvatarManager.instance.avatar(for: item.jid, on: item.account)); } cell.accessoryType = (tableView.indexPathsForSelectedRows?.contains(indexPath) ?? false) ? .checkmark : .none; return cell; } private func selectionChanged() { self.navigationItem.rightBarButtonItem?.isEnabled = !(self.tableView.indexPathsForSelectedRows?.isEmpty ?? true); } func operationStarted(message: String) { // let refreshControl = UIRefreshControl(); // self.tableView.refreshControl = refreshControl; // self.tableView.refreshControl?.attributedTitle = NSAttributedString(string: message); // self.tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - self.tableView.refreshControl!.frame.height), animated: true) // refreshControl.beginRefreshing(); } func operationEnded() { // self.tableView.refreshControl?.endRefreshing(); // self.tableView.refreshControl = nil; } } ================================================ FILE: SiskinIM/channel/ChannelJoinViewController.swift ================================================ // // ChannelJoinViewController.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import TigaseLogging class ChannelJoinViewController: UITableViewController { @IBOutlet var joinButton: UIBarButtonItem!; @IBOutlet var nameField: UILabel!; @IBOutlet var jidField: UILabel!; @IBOutlet var nicknameField: UITextField!; @IBOutlet var passwordField: UITextField!; @IBOutlet var bookmarkCreateSwitch: UISwitch!; @IBOutlet var bookmarkAutojoinSwitch: UISwitch!; private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ChannelJoinViewController"); var client: XMPPClient!; var channelJid: BareJID!; var name: String?; var componentType: ChannelsHelper.ComponentType = .mix; var passwordRequired: Bool = false; var action: Action = .join var password: String?; var nickname: String? = nil; var mixInvitation: MixInvitation? { didSet { if let value = mixInvitation { channelJid = value.channel; componentType = .mix; action = .join; name = value.channel.stringValue; } } } var roomFeatures: [String]?; var fromBookmark: Bool = false; var onConversationJoined: ((Conversation)->Void)?; override func viewDidLoad() { super.viewDidLoad(); self.tableView.contentInsetAdjustmentBehavior = .always; } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); self.nameField.text = name; self.jidField.text = channelJid?.stringValue; self.passwordField.text = password self.nicknameField.text = self.nickname ?? AccountManager.getAccount(for: self.client.userBareJid)?.nickname; bookmarkCreateSwitch.isOn = Settings.enableBookmarksSync; switch action { case .join: if componentType == .muc { operationStarted(message: NSLocalizedString("Checking…", comment: "channel join view operation label")); client.module(.disco).getInfo(for: JID(channelJid), node: nil, completionHandler: { result in switch result { case .success(let info): DispatchQueue.main.async { if let name = info.identities.first?.name { self.nameField.text = name; } self.roomFeatures = info.features; self.passwordRequired = info.features.contains("muc_passwordprotected"); self.tableView.reloadData(); self.updateJoinButtonStatus(); self.operationEnded(); } case .failure(_): DispatchQueue.main.async { self.roomFeatures = []; self.updateJoinButtonStatus(); self.operationEnded(); } } }); } else { self.updateJoinButtonStatus(); } joinButton.title = NSLocalizedString("Join", comment: "button label"); default: joinButton.title = NSLocalizedString("Create", comment: "button label"); updateJoinButtonStatus(); break; } } @objc func cancelClicked(_ sender: Any) { self.dismiss(animated: true, completion: nil); } @IBAction func joinClicked(_ sender: Any) { let nick = self.nicknameField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""; let password = (passwordField?.text?.isEmpty ?? true) ? nil : passwordField.text; guard !nick.isEmpty else { return; } switch action { case .join: self.join(nick: nick, password: password); case .create(let isPublic, let invitationOnly, let description, let avatar): self.create(name: name!, description: description, nick: nick, isPublic: isPublic, invitationOnly: invitationOnly, avatar: avatar); } } override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { if (!passwordRequired && section == 2) || ((fromBookmark || componentType == .mix) && section == 3) { return 0.1; } return super.tableView(tableView, heightForHeaderInSection: section); } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if (!passwordRequired && section == 2) || ((fromBookmark || componentType == .mix) && section == 3) { return 0; } return super.tableView(tableView, numberOfRowsInSection: section); } override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { if (!passwordRequired && section == 2) || ((fromBookmark || componentType == .mix) && section == 3) { return 0.1; } return super.tableView(tableView, heightForFooterInSection: section); } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { if (!passwordRequired && section == 2) || ((fromBookmark || componentType == .mix) && section == 3) { return nil; } return super.tableView(tableView, titleForHeaderInSection: section); } func operationStarted(message: String) { self.tableView.refreshControl = UIRefreshControl(); self.tableView.refreshControl?.attributedTitle = NSAttributedString(string: message); self.tableView.refreshControl?.isHidden = false; self.tableView.refreshControl?.layoutIfNeeded(); self.tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - self.tableView.refreshControl!.frame.height), animated: true) self.tableView.refreshControl?.beginRefreshing(); } func operationEnded() { if let tableView = self.tableView { tableView.refreshControl?.endRefreshing(); tableView.refreshControl = nil; } } @IBAction func textFieldChanged(_ sender: Any) { updateJoinButtonStatus(); } @IBAction func bookmarkCreateChanged(_ sender: UISwitch) { bookmarkAutojoinSwitch.isEnabled = sender.isOn; } private func updateJoinButtonStatus() { let nick = self.nicknameField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""; self.joinButton.isEnabled = (!nick.isEmpty) && ((!passwordRequired) || (!(passwordField.text?.isEmpty ?? true))); } private func create(name: String, description: String?, nick: String, isPublic: Bool, invitationOnly: Bool, avatar: UIImage?) { let client = self.client!; switch componentType { case .mix: let mixModule = client.module(.mix); self.operationStarted(message: NSLocalizedString("Creating channel…", comment: "channel join view operation label")) mixModule.create(channel: channelJid.localPart, at: BareJID(domain: channelJid.domain), completionHandler: { [weak self] result in switch result { case .success(let channelJid): mixModule.join(channel: channelJid, withNick: nick, completionHandler: { result in DispatchQueue.main.async { self?.operationEnded(); } switch result { case .success(_): DispatchQueue.main.async { self?.dismiss(animated: true, completion: nil); if let channel = DBChatStore.instance.channel(for: client, with: channelJid) { self?.onConversationJoined?(channel); } } case .failure(let error): DispatchQueue.main.async { guard let that = self else { return; } let alert = UIAlertController(title: NSLocalizedString("Error occurred", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Could not join newly created channel '%@' on the server. Got following error: %@", comment: "alert body"), channelJid.stringValue, error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); that.present(alert, animated: true, completion: nil); } } }) mixModule.publishInfo(for: channelJid, info: ChannelInfo(name: name, description: description, contact: []), completionHandler: nil); if let avatarData = avatar?.scaled(maxWidthOrHeight: 512.0)?.jpegData(compressionQuality: 0.8) { client.module(.pepUserAvatar).publishAvatar(at: channelJid, data: avatarData, mimeType: "image/jpeg", completionHandler: { result in self?.logger.debug("avatar publication result: \(result)"); }); } if invitationOnly { mixModule.changeAccessPolicy(of: channelJid, isPrivate: invitationOnly, completionHandler: { result in self?.logger.debug("changed channel access policy: \(result)"); }) } case .failure(let error): DispatchQueue.main.async { self?.operationEnded(); guard let that = self else { return; } let alert = UIAlertController(title: NSLocalizedString("Error occurred", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Could not create channel on the server. Got following error: %@", comment: "alert body"), error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); that.present(alert, animated: true, completion: nil); } } }) break; case .muc: let mucModule = client.module(.muc); let priv = !isPublic; let roomName = isPublic ? channelJid.localPart! : UUID().uuidString; let createBookmark = bookmarkCreateSwitch.isOn; let autojoin = createBookmark && bookmarkAutojoinSwitch.isOn; let form = JabberDataElement(type: .submit); form.addField(HiddenField(name: "FORM_TYPE")).value = "http://jabber.org/protocol/muc#roomconfig"; form.addField(TextSingleField(name: "muc#roomconfig_roomname", value: name)); form.addField(BooleanField(name: "muc#roomconfig_membersonly", value: priv)); form.addField(BooleanField(name: "muc#roomconfig_publicroom", value: !priv)); // form.addField(TextSingleField(name: "muc#roomconfig_roomdesc", value: channelDescription)); form.addField(TextSingleField(name: "muc#roomconfig_whois", value: priv ? "anyone" : "moderators")) let mucServer = self.channelJid.domain; self.operationStarted(message: NSLocalizedString("Creating channel…", comment: "channel join view operation label")) mucModule.setRoomConfiguration(roomJid: JID(BareJID(localPart: roomName, domain: mucServer)), configuration: form, completionHandler: { [weak self] configResult in mucModule.join(roomName: roomName, mucServer: mucServer, nickname: nick).handle({ [weak self] joinResult in switch joinResult { case .success(let r): switch r { case .created(let room), .joined(let room): if createBookmark { client.module(.pepBookmarks).addOrUpdate(bookmark: Bookmarks.Conference(name: name.isEmpty ? room.jid.localPart : name, jid: JID(room.jid), autojoin: autojoin, nick: nick, password: nil)); } var features = Set(); features.insert(.nonAnonymous); if priv { features.insert(.membersOnly); } (room as! Room).roomFeatures = features; let vcard = VCard(); if let binval = avatar?.scaled(maxWidthOrHeight: 512.0)?.jpegData(compressionQuality: 0.8)?.base64EncodedString(options: []) { vcard.photos = [VCard.Photo(uri: nil, type: "image/jpeg", binval: binval, types: [.home])]; } client.module(.vcardTemp).publishVCard(vcard, to: room.jid, completionHandler: nil); if description != nil { mucModule.setRoomSubject(roomJid: room.jid, newSubject: description); } let finished = { DispatchQueue.main.async { self?.operationEnded(); self?.dismiss(animated: true, completion: nil); self?.onConversationJoined?(room as! Room); } } switch configResult { case .success(_): finished(); case .failure(_): mucModule.setRoomConfiguration(roomJid: JID(room.jid), configuration: form, completionHandler: { configResult in switch configResult { case .failure(let error): DispatchQueue.main.async { self?.operationEnded(); guard let that = self else { return; } let alert = UIAlertController(title: NSLocalizedString("Error occurred", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Room was created and joined but room was not properly configured. Got following error: %@", comment: "alert body"), error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("Destroy", comment: "button label"), style: .destructive, handler: { _ in room.context?.module(.muc).destroy(room: room); finished(); })) alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: { _ in finished(); })); that.present(alert, animated: true, completion: nil); } case .success(_): finished(); } }) } } case .failure(let error): DispatchQueue.main.async { self?.operationEnded(); guard let that = self else { return; } let alert = UIAlertController(title: NSLocalizedString("Error occurred", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Could not create channel on the server. Got following error: %@", comment: "alert body"), error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); that.present(alert, animated: true, completion: nil); } } }) }) } } private func join(nick: String, password: String?) { let client = self.client!; guard (!passwordRequired) || (password != nil) else { return; } switch componentType { case .mix: self.operationStarted(message: NSLocalizedString("Joining…", comment: "channel join view operation label")); client.module(.mix).join(channel: channelJid, withNick: nick, invitation: mixInvitation, completionHandler: { result in switch result { case .success(_): // we have joined, so all what we need to do is close this window DispatchQueue.main.async { self.operationEnded(); self.dismiss(animated: true, completion: nil); if let channel = DBChatStore.instance.channel(for: client, with: self.channelJid) { self.onConversationJoined?(channel); } } case .failure(let error): DispatchQueue.main.async { [weak self] in self?.operationEnded(); let alert = UIAlertController(title: NSLocalizedString("Could not join", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("It was not possible to join a channel. The server returned an error: %@", comment: "alert button"), error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); self?.present(alert, animated: true, completion: nil); } } }); case .muc: let room = channelJid!; let createBookmark = bookmarkCreateSwitch.isOn; let autojoin = createBookmark && bookmarkAutojoinSwitch.isOn; self.operationStarted(message: NSLocalizedString("Joining…", comment: "channel join view operation label")); client.module(.muc).join(roomName: room.localPart!, mucServer: room.domain, nickname: nick, password: password).handle({ result in switch result { case .success(let joinResult): DispatchQueue.main.async { self.operationEnded(); } switch joinResult { case .created(let room), .joined(let room): client.module(.disco).getInfo(for: JID(room.jid), completionHandler: { result in switch result { case .success(let info): if createBookmark { client.module(.pepBookmarks).addOrUpdate(bookmark: Bookmarks.Conference(name: info.identities.first?.name ?? room.jid.localPart, jid: JID(room.jid), autojoin: autojoin, nick: nick, password: password)); } (room as! Room).roomFeatures = Set(info.features.compactMap({ Room.Feature(rawValue: $0) })); case .failure(_): break; } }); (room as! Room).registerForTigasePushNotification(true, completionHandler: { (result) in self.logger.debug("automatically enabled push for: \(room.jid), result: \(result)"); }) defer { DispatchQueue.main.async { self.onConversationJoined?(room as! Room); } } } if createBookmark { client.module(.pepBookmarks).addOrUpdate(bookmark: Bookmarks.Conference(name: room.localPart!, jid: JID(room), autojoin: autojoin, nick: nick, password: password)); } DispatchQueue.main.async { self.dismiss(animated: true, completion: nil); } case .failure(let error): DispatchQueue.main.async { [weak self] in self?.operationEnded(); let alert = UIAlertController(title: NSLocalizedString("Could not join", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("It was not possible to join a channel. The server returned an error: %@", comment: "alert button"), error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); self?.present(alert, animated: true, completion: nil); } } }); } } enum Action { case create(isPublic: Bool, invitationOnly: Bool, description: String?, avatar: UIImage?) case join } } ================================================ FILE: SiskinIM/channel/ChannelParticipantsController.swift ================================================ // // ChannelParticipantsController.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine class ChannelParticipantsController: UITableViewController { var channel: Channel!; private var participants: [MixParticipant] = []; private var invitationOnly: Bool = false; private var dispatcher = QueueDispatcher(label: "ChannelParticipantsController"); private var cancellables: Set = []; override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); channel.participantsPublisher.throttle(for: 0.2, scheduler: dispatcher.queue, latest: true).sink(receiveValue: { [weak self] participants in self?.update(participants: participants); }).store(in: &cancellables); if channel.permissions?.contains(.changeConfig) ?? false, let mixModule = channel.context?.module(.mix) { self.operationStarted(message: NSLocalizedString("Refreshing…", comment: "channel participants view operation")); mixModule.checkAccessPolicy(of: channel.channelJid, completionHandler: { [weak self] result in DispatchQueue.main.async { switch result { case .success(let invitiationOnly): if let that = self { that.invitationOnly = invitiationOnly; if invitiationOnly { that.navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "plus"), style: .plain, target: that, action: #selector(that.inviteToChannel(_:))); } else { that.navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "folder"), style: .plain, target: that, action: #selector(that.manageBlocked(_:))); } } case .failure(_): break; } self?.operationEnded(); } }); } } override func viewDidDisappear(_ animated: Bool) { cancellables.removeAll(); super.viewDidDisappear(animated); } override func numberOfSections(in tableView: UITableView) -> Int { return 1; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return participants.count; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let participant = self.participants[indexPath.row]; let cell = tableView.dequeueReusableCell(withIdentifier: "ChannelParticipantTableViewCell", for: indexPath) as! ChannelParticipantTableViewCell; cell.set(participant: participant, in: channel); return cell; } override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard channel.permissions?.contains(.changeConfig) ?? false else { return nil; } guard let jid = self.participants[indexPath.row].jid else { return nil; } let account = self.channel.account; guard account != jid else { return nil; } return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { actions -> UIMenu? in let block = UIAction(title: NSLocalizedString("Block participant", comment: "action"), image: UIImage(systemName: "hand.raised.fill"), handler: { action in guard let mixModule = self.channel.context?.module(.mix) else { return; } self.operationStarted(message: NSLocalizedString("Blocking…", comment: "channel participants view operation")); let channelJid = self.channel.channelJid; if self.invitationOnly { mixModule.allowAccess(to: channelJid, for: jid, value: false, completionHandler: { [weak self] result in DispatchQueue.main.async { self?.operationEnded(); } }); } else { mixModule.denyAccess(to: channelJid, for: jid, value: true, completionHandler: { [weak self] result in DispatchQueue.main.async { self?.operationEnded(); } }); } }); return UIMenu(title: "", children: [block]); }); } @objc func inviteToChannel(_ sender: Any) { self.performSegue(withIdentifier: "showChannelInviteController", sender: self); } @objc func manageBlocked(_ sender: Any) { self.performSegue(withIdentifier: "showChannelBlocked", sender: self); } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if let destination = segue.destination as? ChannelInviteController { destination.channel = self.channel; } if let destination = segue.destination as? ChannelBlockedUsersController { destination.channel = self.channel; } } private func update(participants: [MixParticipant]) { let oldParticipants = self.participants; let newParticipants = participants.sorted(by: { (p1,p2) -> Bool in return (p1.nickname ?? p1.id).caseInsensitiveCompare(p2.nickname ?? p2.id) == .orderedAscending; }); let changes = newParticipants.calculateChanges(from: oldParticipants); DispatchQueue.main.sync { self.participants = newParticipants; self.tableView?.beginUpdates(); self.tableView?.deleteRows(at: changes.removed.map({ IndexPath(row: $0, section: 0)}), with: .fade); self.tableView?.insertRows(at: changes.inserted.map({ IndexPath(row: $0, section: 0)}), with: .fade); self.tableView?.endUpdates(); } } func operationStarted(message: String) { self.tableView.refreshControl = UIRefreshControl(); self.tableView.refreshControl?.attributedTitle = NSAttributedString(string: message); self.tableView.refreshControl?.isHidden = false; self.tableView.refreshControl?.layoutIfNeeded(); self.tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - self.tableView.refreshControl!.frame.height), animated: true) self.tableView.refreshControl?.beginRefreshing(); } func operationEnded() { self.tableView.refreshControl?.endRefreshing(); self.tableView.refreshControl = nil; } } class ChannelParticipantTableViewCell: UITableViewCell { @IBOutlet var avatarView: AvatarView!; @IBOutlet var labelView: UILabel!; @IBOutlet var jidView: UILabel!; static func labelViewFont() -> UIFont { let preferredFont = UIFont.preferredFont(forTextStyle: .subheadline); let fontDescription = preferredFont.fontDescriptor.withSymbolicTraits(.traitBold)!; return UIFont(descriptor: fontDescription, size: preferredFont.pointSize); } func set(participant: MixParticipant, in channel: Channel) { let jid = participant.jid ?? BareJID(localPart: "\(participant.id)#\(channel.channelJid.localPart!)", domain: channel.channelJid.domain); avatarView?.set(name: participant.nickname ?? participant.id, avatar: AvatarManager.instance.avatar(for: jid, on: channel.account)); labelView.font = ChannelParticipantTableViewCell.labelViewFont(); labelView?.text = participant.nickname; jidView?.text = participant.jid?.stringValue ?? participant.id } } ================================================ FILE: SiskinIM/channel/ChannelSelectAccountAndComponentController.swift ================================================ // // ChannelSelectAccountAndComponentController.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class ChannelSelectAccountAndComponentController: UITableViewController, UIPickerViewDataSource, UIPickerViewDelegate { @IBOutlet var accountField: UITextField!; @IBOutlet var componentField: UITextField!; weak var delegate: ChannelSelectAccountAndComponentControllerDelgate?; private let accountPicker = UIPickerView(); override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); let accountPicker = UIPickerView(); accountPicker.dataSource = self; accountPicker.delegate = self; accountField.inputView = accountPicker; accountField.text = delegate?.client?.userBareJid.stringValue; componentField?.text = delegate?.domain; } override func viewWillDisappear(_ animated: Bool) { if let account = BareJID(accountField!.text), let client = XmppService.instance.getClient(for: account) { delegate?.client = client; } let val = componentField.text?.trimmingCharacters(in: .whitespacesAndNewlines); delegate?.domain = (val?.isEmpty ?? true) ? nil : val; super.viewWillDisappear(animated); } func numberOfComponents(in pickerView: UIPickerView) -> Int { return 1; } func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { return AccountManager.getActiveAccounts().count; } func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { return AccountManager.getActiveAccounts()[row].name.stringValue; } func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { self.accountField.text = self.pickerView(pickerView, titleForRow: row, forComponent: component); } } protocol ChannelSelectAccountAndComponentControllerDelgate: AnyObject { var client: XMPPClient? { get set } var domain: String? { get set } } ================================================ FILE: SiskinIM/channel/ChannelSelectNewOwnerViewController.swift ================================================ // // ChannelSelectNewOwnerViewController.swift // // Siskin IM // Copyright (C) 2022 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class ChannelSelectNewOwnerViewController: UITableViewController { @IBOutlet var confirmBtn: UIBarButtonItem!; var participants: [MixParticipant] = []; var channel: Channel! = nil; var selected: MixParticipant? { didSet { confirmBtn.isEnabled = selected != nil; } } var completionHandler: ((MixParticipant?)->Void)?; @IBAction func cancelTapped(_ sender: Any) { self.navigationController?.dismiss(animated: true); } @IBAction func doneTapped(_ sender: Any) { completionHandler?(selected); self.navigationController?.dismiss(animated: true); } override func numberOfSections(in tableView: UITableView) -> Int { return 1; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return participants.count; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "ChannelParticipantTableViewCell", for: indexPath) as! ChannelParticipantTableViewCell; cell.set(participant: participants[indexPath.row], in: channel); return cell; } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { selected = participants[indexPath.row]; } } ================================================ FILE: SiskinIM/channel/ChannelSelectToJoinViewController.swift ================================================ // // ChannelJoinViewController.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import TigaseLogging class ChannelSelectToJoinViewController: UITableViewController, UISearchResultsUpdating, ChannelSelectAccountAndComponentControllerDelgate { @IBOutlet var joinButton: UIBarButtonItem!; @IBOutlet var statusView: ChannelJoinStatusView!; weak var client: XMPPClient? { didSet { statusView.account = client?.userBareJid; needRefresh = true; } } var domain: String? { didSet { statusView.server = domain; needRefresh = true; } } var joinConversation: (BareJID,String?)? { didSet { domain = joinConversation?.0.domain; } } private var components: [ChannelsHelper.Component] = []; private var allItems: [DiscoveryModule.Item] = []; private var items: [DiscoveryModule.Item] = []; private var needRefresh: Bool = false; private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ChannelSelectToJoinViewController"); override func viewDidLoad() { super.viewDidLoad(); self.tableView.contentInsetAdjustmentBehavior = .always; let searchController = UISearchController(searchResultsController: nil); self.navigationItem.hidesSearchBarWhenScrolling = false; searchController.hidesNavigationBarDuringPresentation = false; searchController.searchResultsUpdater = self searchController.searchBar.searchBarStyle = .prominent; searchController.searchBar.isOpaque = false; searchController.searchBar.isTranslucent = true; searchController.searchBar.placeholder = NSLocalizedString("Search channels", comment: "search bar placeholder"); self.navigationItem.searchController = searchController; // definesPresentationContext = true; } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); if client == nil { if let account = AccountManager.getActiveAccounts().first?.name { client = XmppService.instance.getClient(for: account); } } if needRefresh { self.refreshItems(); needRefresh = false; } } override func numberOfSections(in tableView: UITableView) -> Int { return 1; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return self.items.count; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "ChannelJoinCellView", for: indexPath); let item = items[indexPath.row]; cell.textLabel?.text = item.name ?? item.jid.localPart; cell.detailTextLabel?.text = item.jid.stringValue; return cell; } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { self.joinButton.isEnabled = true; } override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { self.joinButton.isEnabled = false; } private var queryRemote: String?; func updateSearchResults(for searchController: UISearchController) { updateItems(); self.queryRemote = searchController.searchBar.text; DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: { [weak self] in guard let that = self, let remoteQuery = that.queryRemote, let client = that.client, let text = searchController.searchBar.text, remoteQuery == text else { self?.logger.debug("remote query \(self?.queryRemote as Any) , text: \(searchController.searchBar.text as Any)"); return; } that.queryRemote = nil; that.logger.debug("executing query for: \(text)"); ChannelsHelper.queryChannel(for: client, at: that.components, name: text, completionHandler: { result in switch result { case .success(let items): self?.logger.debug("got items: \(items)"); DispatchQueue.main.async { guard let that = self else { return; } var changed = false; for item in items { if that.allItems.first(where: { $0.jid == item.jid }) == nil { that.allItems.append(item); changed = true; } } if changed { that.updateItems(); } } case .failure(let err): self?.logger.debug("got error: \(err.description)"); } }) }); } @IBAction func cancelClicked(_ sender: Any) { self.dismiss(animated: true, completion: nil); } @IBAction func changeAccountOrComponentClicked(_ sender: Any) { self.performSegue(withIdentifier: "ChannelSelectAccountAndComponentSegue", sender: sender); } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if let destination = segue.destination as? ChannelSelectAccountAndComponentController { destination.delegate = self; } if let destination = segue.destination as? ChannelJoinViewController { destination.client = self.client; if let selected = tableView.indexPathForSelectedRow { let item = self.items[selected.row]; destination.channelJid = item.jid.bareJid; destination.name = item.name ?? item.jid.localPart; destination.componentType = self.components.first(where: { $0.jid.domain == item.jid.domain })?.type ?? .mix; destination.password = self.joinConversation?.1; } } } func operationStarted() { guard !(self.refreshControl?.isRefreshing ?? false) else { return; } self.tableView.refreshControl = UIRefreshControl(); self.tableView.refreshControl?.attributedTitle = NSAttributedString(string: NSLocalizedString("Updating…", comment: "refresh conrol label")); self.tableView.refreshControl?.isHidden = false; self.tableView.refreshControl?.layoutIfNeeded(); self.tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - self.tableView.refreshControl!.frame.height), animated: true) self.tableView.refreshControl?.beginRefreshing(); } func operationFinished() { self.tableView.refreshControl?.endRefreshing(); self.tableView.refreshControl = nil; } private func refreshItems() { guard let client = self.client else { return; } let domain = self.domain ?? client.userBareJid.domain; self.operationStarted(); ChannelsHelper.findComponents(for: client, at: domain, completionHandler: { [weak self] components in guard let that = self, that.client?.userBareJid == client.userBareJid else { return; } let currDomain = that.domain ?? client.userBareJid.domain; guard currDomain == domain else { return; } that.components = components; if let data = that.joinConversation, let name = data.0.localPart { ChannelsHelper.queryChannel(for: client, at: that.components, name: name, completionHandler: { result in switch result { case .success(let items): DispatchQueue.main.async { guard let that = self else { return; } var changed = false; for item in items { if that.allItems.first(where: { $0.jid == item.jid }) == nil { that.allItems.append(item); changed = true; } } if changed { that.updateItems(); } that.operationFinished(); } case .failure(let err): break; } }) } else { ChannelsHelper.findChannels(for: client, at: components, completionHandler: { [weak self] allItems in guard let that = self, that.client?.userBareJid == client.userBareJid else { return; } let currDomain = that.domain ?? client.userBareJid.domain; guard currDomain == domain else { return; } that.allItems = allItems; that.updateItems(); that.operationFinished(); }) if components.isEmpty { DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Service unavailable", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("There is no service supporting channels for domain %@", comment: "alert message"), domain), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default)); that.present(alert, animated: true, completion: nil); } } } }) } private func updateItems() { let prefix = self.navigationItem.searchController?.searchBar.text ?? ""; let items = prefix.isEmpty ? allItems : allItems.filter({ item -> Bool in return (item.name?.starts(with: prefix) ?? false) || item.jid.stringValue.contains(prefix); }); self.items = items.sorted(by: { (i1, i2) -> Bool in return (i1.name ?? i1.jid.stringValue).caseInsensitiveCompare(i2.name ?? i2.jid.stringValue) == .orderedAscending; }); tableView.reloadData(); joinButton.isEnabled = false; } } class ChannelJoinStatusView: UIBarButtonItem { var account: BareJID? { didSet { let value = NSMutableAttributedString(string: "\(NSLocalizedString("Account", comment: "channel join status view label")): ", attributes: [.font: UIFont.preferredFont(forTextStyle: .caption1), .foregroundColor: UIColor.secondaryLabel]); value.append(NSAttributedString(string: account?.stringValue ?? NSLocalizedString("None", comment: "channel join status view label"), attributes: [.font: UIFont.preferredFont(forTextStyle: .caption1), .foregroundColor: UIColor(named: "tintColor")!])); accountLabel.attributedText = value; } } var server: String? { didSet { let value = NSMutableAttributedString(string: "\(NSLocalizedString("Component", comment: "channel join status view label")): ", attributes: [.font: UIFont.preferredFont(forTextStyle: .caption1), .foregroundColor: UIColor.secondaryLabel]); value.append(NSAttributedString(string: server ?? NSLocalizedString("Automatic", comment: "channel join status view label"), attributes: [.font: UIFont.preferredFont(forTextStyle: .caption1), .foregroundColor: UIColor(named: "tintColor")!])); serverLabel.attributedText = value; } } private var accountLabel: UILabel!; private var serverLabel: UILabel!; override init() { super.init(); setup(); } required init?(coder: NSCoder) { super.init(coder: coder); setup(); } func setup() { let view = UIView(); view.translatesAutoresizingMaskIntoConstraints = false; self.accountLabel = UILabel(); accountLabel.isUserInteractionEnabled = false; accountLabel.font = UIFont.systemFont(ofSize: UIFont.smallSystemFontSize); accountLabel.translatesAutoresizingMaskIntoConstraints = false; accountLabel.text = "\(NSLocalizedString("Account", comment: "channel join status view label")): \(NSLocalizedString("None", comment: "channel join status view label"))"; self.serverLabel = UILabel(); serverLabel.isUserInteractionEnabled = false; serverLabel.font = UIFont.systemFont(ofSize: UIFont.smallSystemFontSize); if #available(iOS 13.0, *) { serverLabel.textColor = UIColor.secondaryLabel; } else { serverLabel.textColor = UIColor.darkGray; } serverLabel.translatesAutoresizingMaskIntoConstraints = false; serverLabel.text = "\(NSLocalizedString("Component", comment: "channel join status view label")): \(NSLocalizedString("Automatic", comment: "channel join status view label"))"; view.addSubview(accountLabel); view.addSubview(serverLabel); NSLayoutConstraint.activate([ view.topAnchor.constraint(equalTo: accountLabel.topAnchor), view.leadingAnchor.constraint(equalTo: accountLabel.leadingAnchor), view.trailingAnchor.constraint(greaterThanOrEqualTo: accountLabel.trailingAnchor), accountLabel.bottomAnchor.constraint(equalTo: serverLabel.topAnchor), view.leadingAnchor.constraint(equalTo: serverLabel.leadingAnchor), view.trailingAnchor.constraint(greaterThanOrEqualTo: serverLabel.trailingAnchor), view.bottomAnchor.constraint(equalTo: serverLabel.bottomAnchor) ]) self.customView = view; } } ================================================ FILE: SiskinIM/channel/ChannelSettingsViewController.swift ================================================ // // ChannelSettingsViewController.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine import Shared class ChannelSettingsViewController: UITableViewController { @IBOutlet var channelNameField: UILabel!; @IBOutlet var channelAvatarView: AvatarView! @IBOutlet var channelDescriptionField: UILabel!; @IBOutlet var notificationsField: UILabel!; var channel: Channel!; private var cancellables: Set = []; override func viewWillAppear(_ animated: Bool) { channel.displayNamePublisher.map({ $0 }).assign(to: \.text, on: channelNameField!).store(in: &cancellables); channelAvatarView.layer.cornerRadius = channelAvatarView.frame.width / 2; channelAvatarView.layer.masksToBounds = true; channel.avatarPublisher.replaceNil(with: AvatarManager.instance.defaultGroupchatAvatar).assign(to: \.avatar, on: channelAvatarView).store(in: &cancellables); channel.descriptionPublisher.assign(to: \.text, on: channelDescriptionField).store(in: &cancellables); channel.optionsPublisher.map({ ChannelSettingsViewController.labelFor(conversationNotification: $0.notifications) as String? }).assign(to: \.text, on: notificationsField!).store(in: &cancellables); refresh(); refreshPermissions(); } override func viewDidDisappear(_ animated: Bool) { self.cancellables.removeAll(); super.viewDidDisappear(animated) } @IBAction func dismissView() { self.dismiss(animated: true, completion: nil); } func refresh() { guard let mixModule = channel.context?.module(.mix) else { return; } operationStarted(message: NSLocalizedString("Checking…", comment: "channel settings view opeartion label")); let channel = self.channel!; let dispatchGroup = DispatchGroup(); if channel.permissions == nil { dispatchGroup.enter(); mixModule.retrieveAffiliations(for: channel, completionHandler: { [weak self] result in DispatchQueue.main.async { self?.refreshPermissions(); } dispatchGroup.leave(); }) } dispatchGroup.enter(); mixModule.retrieveAvatar(for: channel.channelJid, completionHandler: { result in switch result { case .success(let avatarInfo): if !AvatarManager.instance.hasAvatar(withHash: avatarInfo.id) { AvatarManager.instance.retrievePepUserAvatar(for: channel.channelJid, on: channel.account, hash: avatarInfo.id); } case .failure(_): break; } dispatchGroup.leave(); }) dispatchGroup.notify(queue: DispatchQueue.main, execute: self.operationEnded); } func refreshPermissions() { navigationItem.rightBarButtonItem?.isEnabled = channel.permissions?.contains(.changeInfo) ?? false; editButtonItem.isEnabled = channel.permissions?.contains(.changeInfo) ?? false; tableView.reloadData(); } func operationStarted(message: String) { self.tableView.refreshControl = UIRefreshControl(); self.tableView.refreshControl?.attributedTitle = NSAttributedString(string: message); self.tableView.refreshControl?.isHidden = false; self.tableView.refreshControl?.layoutIfNeeded(); self.tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - self.tableView.refreshControl!.frame.height), animated: true) self.tableView.refreshControl?.beginRefreshing(); } func operationEnded() { self.tableView.refreshControl?.endRefreshing(); self.tableView.refreshControl = nil; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if section == 3 && !(channel.permissions?.contains(.changeConfig) ?? false) { return 0; } return super.tableView(tableView, numberOfRowsInSection: section); } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true); if indexPath.section == 1 && indexPath.row == 0 { let controller = TablePickerViewController(options: [.always, .mention, .none], value: channel.options.notifications, labelFn: ChannelSettingsViewController.labelFor(conversationNotification: )); controller.sink(receiveValue: { [weak self] value in self?.channel.updateOptions({ options in options.notifications = value; }, completionHandler: { if let account = self?.channel.account, let pushModule = self?.channel.context?.module(.push) as? SiskinPushNotificationsModule, let pushSettings = pushModule.pushSettings { pushModule.reenable(pushSettings: pushSettings, completionHandler: { result in switch result { case .success(_): break; case .failure(_): AccountSettings.pushHash(for: account, value: 0); } }); } }) }); self.navigationController?.pushViewController(controller, animated: true); } if indexPath.section == 3 && indexPath.row == 0, let channel = self.channel { let alertController = UIAlertController(title: NSLocalizedString("Delete channel?", comment: "alert title"), message: NSLocalizedString("All messages will be deleted and all participants will be kicked out. Are you sure?", comment: "alert body"), preferredStyle: .actionSheet); alertController.addAction(UIAlertAction(title: NSLocalizedString("Yes", comment: "button label"), style: .destructive, handler: { action in guard let mixModule = channel.context?.module(.mix) else { return; } // -- handle this properly!! mixModule.destroy(channel: channel.channelJid, completionHandler: { [weak self] result in DispatchQueue.main.async { switch result { case .success(_): self?.dismiss(animated: true, completion: nil); case .failure(let error): guard let that = self else { return; } let alert = UIAlertController(title: NSLocalizedString("Channel destruction failed!", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("It was not possible to destroy channel %@. Server returned an error: %@", comment: "alert body"), channel.name ?? channel.channelJid.stringValue, error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); that.present(alert, animated: true, completion: nil); } } }); })); alertController.addAction(UIAlertAction(title: NSLocalizedString("No", comment: "button label"), style: .cancel, handler: nil)); alertController.popoverPresentationController?.sourceView = self.tableView; alertController.popoverPresentationController?.sourceRect = self.tableView.rectForRow(at: indexPath); self.present(alertController, animated: true, completion: nil); } } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == "chatShowAttachments" { if let attachmentsController = segue.destination as? ChatAttachmentsController { attachmentsController.conversation = self.channel; } } if let destination = segue.destination as? ChannelEditInfoController { destination.channel = self.channel; } } @IBAction func editClicked(_ sender: UIBarButtonItem) { if channel.permissions?.contains(.changeInfo) ?? false { self.performSegue(withIdentifier: "editChannelInfo", sender: self); } } static func labelFor(conversationNotification type: ConversationNotification) -> String { switch type { case .none: return NSLocalizedString("Muted", comment: "conversation notifications status"); case .mention: return NSLocalizedString("When mentioned", comment: "conversation notifications status"); case .always: return NSLocalizedString("Always", comment: "conversation notifications status"); } } } ================================================ FILE: SiskinIM/channel/ChannelViewController.swift ================================================ // // ChannelViewController.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine class ChannelViewController: BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar { var titleView: ChannelTitleView! { get { return self.navigationItem.titleView as? ChannelTitleView } } var channel: Channel { return conversation as! Channel; } private var cancellables: Set = []; override func viewDidLoad() { super.viewDidLoad(); let recognizer = UITapGestureRecognizer(target: self, action: #selector(channelInfoClicked)); self.titleView?.isUserInteractionEnabled = true; self.navigationController?.navigationBar.addGestureRecognizer(recognizer); initializeSharing(); } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); channel.context!.$state.map({ $0 == .connected() }).combineLatest(channel.optionsPublisher).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] (connected, options) in self?.titleView?.refresh(connected: connected, options: options); self?.navigationItem.rightBarButtonItem?.isEnabled = options.state == .joined; }).store(in: &cancellables); channel.displayNamePublisher.map({ $0 }).assign(to: \.name, on: self.titleView).store(in: &cancellables); } override func viewDidDisappear(_ animated: Bool) { cancellables.removeAll(); super.viewDidDisappear(animated); } override func canExecuteContext(action: BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar.ContextAction, forItem item: ConversationEntry, at indexPath: IndexPath) -> Bool { switch action { case .retract: return item.state.direction == .outgoing && channel.context?.state == .connected() && channel.state == .joined; default: return super.canExecuteContext(action: action, forItem: item, at: indexPath); } } override func executeContext(action: BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar.ContextAction, forItem item: ConversationEntry, at indexPath: IndexPath) { switch action { case .retract: guard item.state.direction == .outgoing else { return; } channel.retract(entry: item); default: super.executeContext(action: action, forItem: item, at: indexPath); } } @IBAction func sendClicked(_ sender: UIButton) { self.sendMessage(); } @objc func channelInfoClicked() { self.performSegue(withIdentifier: "ChannelSettingsShow", sender: self); } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { super.prepare(for: segue, sender: sender); if let destination = (segue.destination as? UINavigationController)?.topViewController as? ChannelSettingsViewController { destination.channel = self.channel; } if let destination = segue.destination as? ChannelParticipantsController { destination.channel = self.channel; } } override func sendMessage() { guard let text = messageText, !text.isEmpty else { return; } guard channel.state == .joined else { let alert: UIAlertController? = UIAlertController.init(title: NSLocalizedString("Warning", comment: "alert title"), message: NSLocalizedString("You are not joined to the channel.", comment: "alert body"), preferredStyle: .alert); alert?.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); self.present(alert!, animated: true, completion: nil); return; } channel.sendMessage(text: text, correctedMessageOriginId: self.correctedMessageOriginId); DispatchQueue.main.async { self.messageText = nil; } } override func sendAttachment(originalUrl: URL?, uploadedUrl: String, appendix: ChatAttachmentAppendix, completionHandler: (() -> Void)?) { channel.sendAttachment(url: uploadedUrl, appendix: appendix, originalUrl: originalUrl, completionHandler: completionHandler); } } class ChannelTitleView: UIView { @IBOutlet var nameView: UILabel!; @IBOutlet var statusView: UILabel!; var name: String? { get { return nameView.text; } set { nameView.text = newValue; } } override var intrinsicContentSize: CGSize { return UIView.layoutFittingExpandedSize } override func didMoveToSuperview() { super.didMoveToSuperview(); if let superview = self.superview { NSLayoutConstraint.activate([ self.widthAnchor.constraint(lessThanOrEqualTo: superview.widthAnchor, multiplier: 0.6)]); } } func refresh(connected: Bool, options: ChannelOptions) { if connected { let statusIcon = NSTextAttachment(); var show: Presence.Show?; var desc = NSLocalizedString("Not connected", comment: "channel status label"); switch options.state { case .joined: show = Presence.Show.online; desc = NSLocalizedString("Joined", comment: "channel status label"); case .left: show = nil; desc = NSLocalizedString("Not joined", comment: "channel status label"); } statusIcon.image = AvatarStatusView.getStatusImage(show); let height = statusView.font.pointSize; statusIcon.bounds = CGRect(x: 0, y: -2, width: height, height: height); let statusText = NSMutableAttributedString(attributedString: NSAttributedString(attachment: statusIcon)); statusText.append(NSAttributedString(string: desc)); statusView.attributedText = statusText; } else { statusView.text = "\u{26A0} \(NSLocalizedString("Not connected", comment: "channel status label"))!"; } } } ================================================ FILE: SiskinIM/channel/ChannelsHelper.swift ================================================ // // ChannelsHelper.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin class ChannelsHelper { static func findChannels(for client: XMPPClient, at components: [Component], completionHandler: @escaping ([DiscoveryModule.Item])->Void) { var allItems: [DiscoveryModule.Item] = []; let group = DispatchGroup(); for component in components { group.enter(); client.module(.disco).getItems(for: component.jid, completionHandler: { result in switch result { case .success(let items): DispatchQueue.main.async { allItems.append(contentsOf: items.items); } case .failure(_): break; } group.leave(); }); } group.notify(queue: DispatchQueue.main, execute: { completionHandler(allItems); }) } static func findComponents(for client: XMPPClient, at domain: String, completionHandler: @escaping ([Component])->Void) { let domainJid = JID(domain); var components: [Component] = []; let group = DispatchGroup(); group.enter(); let discoModule = client.module(.disco); retrieveComponent(from: domainJid, name: nil, discoModule: discoModule, completionHandler: { result in switch result { case .success(let component): DispatchQueue.main.async { components.append(component); } group.leave(); case .failure(_): discoModule.getItems(for: domainJid, completionHandler: { result in switch result { case .success(let items): // we need to do disco on all components to find out local mix/muc component.. // maybe this should be done once for all "views"? for item in items.items { group.enter(); self.retrieveComponent(from: item.jid, name: item.name, discoModule: discoModule, completionHandler: { result in switch result { case .success(let component): DispatchQueue.main.async { components.append(component); } case .failure(_): break; } group.leave(); }); } case .failure(_): break; } group.leave(); }); } }) group.notify(queue: DispatchQueue.main, execute: { completionHandler(components); }) } static func queryChannel(for client: XMPPClient, at components: [Component], name: String, completionHandler: @escaping (Result<[DiscoveryModule.Item],XMPPError>)->Void) { var allItems: [DiscoveryModule.Item] = []; let group = DispatchGroup(); let discoModule = client.module(.disco); for component in components { group.enter(); let channelJid = JID(BareJID(localPart: name, domain: component.jid.domain)); discoModule.getInfo(for: channelJid, node: nil, completionHandler: { result in switch result { case .success(let info): DispatchQueue.main.async { allItems.append(DiscoveryModule.Item(jid: channelJid, name: info.identities.first?.name)); } case .failure(_): break; } group.leave(); }); } group.notify(queue: DispatchQueue.main, execute: { completionHandler(.success(allItems)); }) } static func retrieveComponent(from jid: JID, name: String?, discoModule: DiscoveryModule, completionHandler: @escaping (Result)->Void) { discoModule.getInfo(for: jid, completionHandler: { result in switch result { case .success(let info): guard let component = Component(jid: jid, name: name, identities: info.identities, features: info.features) else { completionHandler(.failure(.item_not_found)); return; } completionHandler(.success(component)); case .failure(let errorCondition): completionHandler(.failure(errorCondition)); } }) } enum ComponentType { case muc case mix static func from(identities: [DiscoveryModule.Identity], features: [String]) -> ComponentType? { if identities.first(where: { $0.category == "conference" && $0.type == "mix" }) != nil && features.contains(MixModule.CORE_XMLNS) { return .mix; } if identities.first(where: { $0.category == "conference" }) != nil && features.contains("http://jabber.org/protocol/muc") { return .muc; } return nil; } } class Component { let jid: JID; let name: String?; let type: ComponentType; convenience init?(jid: JID, name: String?, identities: [DiscoveryModule.Identity], features: [String]) { guard let type = ComponentType.from(identities: identities, features: features) else { return nil; } self.init(jid: jid, name: name ?? identities.first(where: { $0.name != nil})?.name, type: type); } init(jid: JID, name: String?, type: ComponentType) { self.jid = jid; self.name = name; self.type = type; } } } ================================================ FILE: SiskinIM/chat/BaseChatViewController+Share.swift ================================================ // // BaseChatViewController+Share.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import MobileCoreServices import Martin import Shared extension ChatViewInputBar { class ShareButton: UIButton { weak var controller: BaseChatViewController?; init(controller: BaseChatViewController) { self.controller = controller; super.init(frame: .zero); setup(); } required init?(coder: NSCoder) { super.init(coder: coder); setup(); } @objc func execute(_ sender: Any) { } func setup() { self.tintColor = UIColor(named: "tintColor"); self.addTarget(self, action: #selector(execute(_:)), for: .touchUpInside); self.contentMode = .scaleToFill; self.contentEdgeInsets = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4); if #available(iOS 13.0, *) { } else { self.widthAnchor.constraint(equalTo: heightAnchor).isActive = true; self.heightAnchor.constraint(equalToConstant: 24).isActive = true; } } } } import AVFoundation extension ChatViewInputBar { class VoiceMessageButton: ShareButton { override func execute(_ sender: Any) { controller?.chatViewInputBar.voiceRecordingView.controller = controller; controller?.chatViewInputBar.startRecordingVoiceMessage(sender); } override func setup() { super.setup(); let image = UIImage(systemName: "mic"); setImage(image, for: .normal); } } } extension BaseChatViewController: URLSessionDelegate { func checkIfEnabledOrAsk(completionHandler: @escaping ()->Void) -> Bool { guard Settings.sharingViaHttpUpload else { let alert = UIAlertController(title: NSLocalizedString("Question", comment: "alert title"), message: NSLocalizedString("When you share files, they are uploaded to HTTP server with unique URL. Anyone who knows the unique URL to the file is able to download it.\nDo you wish to proceed?", comment: "alert body"), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("Yes", comment: "button label"), style: .default, handler: { (action) in Settings.sharingViaHttpUpload = true; completionHandler(); })); alert.addAction(UIAlertAction(title: NSLocalizedString("No", comment: "button label"), style: .cancel, handler: nil)); present(alert, animated: true, completion: nil); return false; } return true; } func initializeSharing() { if AVAudioSession.sharedInstance().recordPermission != .denied { self.chatViewInputBar.addBottomButton(ChatViewInputBar.VoiceMessageButton(controller: self)); } self.chatViewInputBar.addBottomButton(ChatViewInputBar.ShareFileButton(controller: self)); self.chatViewInputBar.addBottomButton(ChatViewInputBar.ShareImageButton(controller: self)); if UIImagePickerController.isSourceTypeAvailable(.camera) { self.chatViewInputBar.addBottomButton(ChatViewInputBar.ShareCameraImageButton(controller: self)); } } func showProgressBar() { if self.progressBar == nil { let progressBar = UIProgressView(progressViewStyle: .bar); progressBar.translatesAutoresizingMaskIntoConstraints = false; self.progressBar = progressBar; self.chatViewInputBar.addSubview(progressBar); NSLayoutConstraint.activate([ self.chatViewInputBar.topAnchor.constraint(equalTo: progressBar.topAnchor), self.chatViewInputBar.leadingAnchor.constraint(equalTo: progressBar.leadingAnchor), self.chatViewInputBar.trailingAnchor.constraint(equalTo: progressBar.trailingAnchor), self.chatViewInputBar.bottomAnchor.constraint(greaterThanOrEqualTo: progressBar.bottomAnchor) ]); } self.progressBar?.isHidden = false; } func hideProgressBar() { self.progressBar?.isHidden = true; } fileprivate func shouldEncryptUploadedFile() -> Bool { switch self.conversation { case let chat as Chat: return chat.options.encryption ?? Settings.messageEncryption == .omemo; case let room as Room: let encryption: ChatEncryption = room.options.encryption ?? (room.features.contains(.omemo) ? Settings.messageEncryption : .none); guard encryption == .none || room.features.contains(.omemo) else { return true; } return encryption == .omemo; default: return false; } } func share(filename: String, url: URL, mimeType suggestedMimeType: String? = nil, completionHandler: @escaping (HTTPFileUploadHelper.UploadResult)->Void) { guard let context = self.conversation.context else { completionHandler(.failure(.unknownError)); return; } guard let values = try? url.resourceValues(forKeys: [.fileSizeKey, .typeIdentifierKey]), let size = values.fileSize else { completionHandler(.failure(.noFileSizeError)); return; } DispatchQueue.main.async { self.showProgressBar(); } var mimeType: String? = nil; if suggestedMimeType != nil { mimeType = suggestedMimeType; } else { if let type = values.typeIdentifier { mimeType = UTTypeCopyPreferredTagWithClass(type as CFString, kUTTagClassMIMEType)?.takeRetainedValue() as String?; } } let encrypted = shouldEncryptUploadedFile(); if encrypted { var iv = Data(count: 12); iv.withUnsafeMutableBytes { (bytes) -> Void in _ = SecRandomCopyBytes(kSecRandomDefault, 12, bytes.baseAddress!); } var key = Data(count: 32); key.withUnsafeMutableBytes { (bytes) -> Void in _ = SecRandomCopyBytes(kSecRandomDefault, 32, bytes.baseAddress!); } let dataProvider = Cipher.FileDataProvider(inputStream: InputStream(url: url)!); let dataConsumer = Cipher.TempFileConsumer()!; let cipher = Cipher.AES_GCM(); let tag = cipher.encrypt(iv: iv, key: key, provider: dataProvider, consumer: dataConsumer); _ = dataConsumer.consume(data: tag); dataConsumer.close(); guard let inputStream = InputStream(url: dataConsumer.url) else { DispatchQueue.main.async { self.hideProgressBar(); } completionHandler(.failure(.noAccessError)); return; } HTTPFileUploadHelper.upload(for: context, filename: filename, inputStream: inputStream, filesize: dataConsumer.size, mimeType: mimeType ?? "application/octet-stream", delegate: self, completionHandler: { result in // we cannot release dataConsumer before the file is uploaded! var tmp = dataConsumer; switch result { case .success(let url): var parts = URLComponents(url: url, resolvingAgainstBaseURL: true)!; parts.scheme = "aesgcm"; parts.fragment = (iv + key).map({ String(format: "%02x", $0) }).joined(); let shareUrl = parts.url!; completionHandler(.success(url: shareUrl, filesize: size, mimeType: mimeType)); case .failure(let error): completionHandler(.failure(error)); } DispatchQueue.main.async { self.hideProgressBar(); } }); } else { guard let inputStream = InputStream(url: url) else { DispatchQueue.main.async { self.hideProgressBar(); } completionHandler(.failure(.noAccessError)); return; } HTTPFileUploadHelper.upload(for: context, filename: filename, inputStream: inputStream, filesize: size, mimeType: mimeType ?? "application/octet-stream", delegate: self, completionHandler: { result in switch result { case .success(let getUri): completionHandler(.success(url: getUri, filesize: size, mimeType: mimeType)); case .failure(let error): completionHandler(.failure(error)); } DispatchQueue.main.async { self.hideProgressBar(); } }); } } func showAlert(shareError: ShareError) { self.showAlert(title: NSLocalizedString("Upload failed", comment: "alert title"), message: shareError.message); } func showAlert(error: Error) { if let shareError = error as? ShareError { self.showAlert(shareError: shareError); } else { self.showAlert(title: NSLocalizedString("Upload failed", comment: "alert title"), message: error.localizedDescription); } } func showAlert(title: String, message: String) { DispatchQueue.main.async { self.hideProgressBar(); let alert = UIAlertController(title: title, message: message, preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); self.present(alert, animated: true, completion: nil); } } func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { self.progressBar?.progress = Float(totalBytesSent) / Float(totalBytesExpectedToSend); if self.progressBar?.progress == 1.0 { self.hideProgressBar(); self.progressBar?.progress = 0; } } } ================================================ FILE: SiskinIM/chat/BaseChatViewController+ShareFile.swift ================================================ // // BaseChatViewController+ShareFile.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import UIKit import MobileCoreServices extension ChatViewInputBar { class ShareFileButton: ShareButton { override func execute(_ sender: Any) { controller?.selectFile(); } override func setup() { super.setup(); let image = UIImage(systemName: "arrow.up.doc"); setImage(image, for: .normal); } } } extension BaseChatViewController: UIDocumentPickerDelegate { func selectFile() { guard checkIfEnabledOrAsk(completionHandler: { [weak self] in self?.selectFile(); }) else { return; } let picker = UIDocumentPickerViewController(documentTypes: [String(kUTTypeData)], in: .open); picker.delegate = self; picker.allowsMultipleSelection = false; self.present(picker, animated: true, completion: nil); } @objc func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { guard let url = urls.first else { return; } controller.dismiss(animated: true, completion: nil); guard url.startAccessingSecurityScopedResource() else { url.stopAccessingSecurityScopedResource(); self.showAlert(shareError: .noAccessError); return; } share(filename: url.lastPathComponent, url: url) { (result) in switch result { case .success(let uploadedUrl, let filesize, let mimetype): url.stopAccessingSecurityScopedResource(); var appendix = ChatAttachmentAppendix() appendix.filename = url.lastPathComponent; appendix.filesize = filesize; appendix.mimetype = mimetype; appendix.state = .downloaded; _ = url.startAccessingSecurityScopedResource(); self.sendAttachment(originalUrl: url, uploadedUrl: uploadedUrl.absoluteString, appendix: appendix, completionHandler: { url.stopAccessingSecurityScopedResource(); }); case .failure(let error): self.showAlert(shareError: error); } } } @objc func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { controller.dismiss(animated: true, completion: nil); } } ================================================ FILE: SiskinIM/chat/BaseChatViewController+ShareMedia.swift ================================================ // // BaseChatViewController+ShareMedia.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import PhotosUI import Shared extension ChatViewInputBar { class LongPressShareButton: ShareButton { @objc func longPress(gesture: UILongPressGestureRecognizer) { } override func setup() { super.setup(); let gesture = UILongPressGestureRecognizer(target: self, action: #selector(longPress(gesture:))); gesture.minimumPressDuration = 1.0; self.addGestureRecognizer(gesture); } } class ShareImageButton: LongPressShareButton { override func execute(_ sender: Any) { if #available(iOS 14.0, *) { controller?.selectPhotoFromLibrary(); } else { controller?.selectPhoto(.photoLibrary) } } override func longPress(gesture: UILongPressGestureRecognizer) { controller?.askMediaQuality = true; if #available(iOS 14.0, *) { controller?.selectPhotoFromLibrary(); } else { controller?.selectPhoto(.photoLibrary) } } override func setup() { super.setup(); let image = UIImage(systemName: "photo"); setImage(image, for: .normal); } } class ShareCameraImageButton: LongPressShareButton { override func execute(_ sender: Any) { controller?.selectPhoto(.camera) } override func longPress(gesture: UILongPressGestureRecognizer) { controller?.askMediaQuality = true; controller?.selectPhoto(.camera); } override func setup() { super.setup(); let image = UIImage(systemName: "camera"); setImage(image, for: .normal); } } } @available(iOS 14.0, *) extension BaseChatViewController: PHPickerViewControllerDelegate { func selectPhotoFromLibrary() { var config = PHPickerConfiguration(); config.selectionLimit = 1; config.filter = .any(of: [.videos, .images]); config.preferredAssetRepresentationMode = .current; let picker = PHPickerViewController(configuration: config); picker.delegate = self; present(picker, animated: true); } func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true); if let provider = results.first?.itemProvider { if provider.canLoadObject(ofClass: UIImage.self) { provider.loadFileRepresentation(forTypeIdentifier: "public.image", completionHandler: self.handleLoaded(imageUrl:error:)); } else if provider.hasItemConformingToTypeIdentifier("public.movie") { provider.loadFileRepresentation(forTypeIdentifier: "public.movie", completionHandler: self.handleLoaded(movieUrl:error:)); } else { showAlert(shareError: .noAccessError); } } } private func handleLoaded(imageUrl url: URL?, error: Error?) { guard let url = url, error == nil else { DispatchQueue.main.async { self.showAlert(shareError: .noAccessError); } return; } guard let localUrl = copyFileLocally(url: url) else { DispatchQueue.main.async { self.showAlert(shareError: .unknownError); } return; } upload(imageUrl: localUrl, fileInfo: ShareFileInfo.from(url: url, defaultSuffix: "jpg")); } private func handleLoaded(movieUrl url: URL?, error: Error?) { guard let url = url, error == nil else { DispatchQueue.main.async { self.showAlert(shareError: .noAccessError); } return; } guard let localUrl = copyFileLocally(url: url) else { DispatchQueue.main.async { self.showAlert(shareError: .unknownError); } return; } upload(movieUrl: localUrl, fileInfo: ShareFileInfo.from(url: url, defaultSuffix: "mov")); } } extension BaseChatViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { func selectPhoto(_ source: UIImagePickerController.SourceType) { guard checkIfEnabledOrAsk(completionHandler: { [weak self] in self?.selectPhoto(source); }) else { return; } let picker = UIImagePickerController(); picker.delegate = self; picker.allowsEditing = false;//true; picker.sourceType = source; picker.mediaTypes = ["public.image", "public.movie"]; present(picker, animated: true, completion: nil); } @objc func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { picker.dismiss(animated: true, completion: nil); } @objc func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { if let movieUrl = info[.mediaURL] as? URL { upload(movieUrl: movieUrl, fileInfo: ShareFileInfo.from(url: movieUrl, defaultSuffix: "mov")); } else if let imageUrl = info[.imageURL] as? URL { upload(imageUrl: imageUrl, fileInfo: ShareFileInfo.from(url: imageUrl, defaultSuffix: "jpg")); } else if let image = (info[.editedImage] as? UIImage) ?? (info[.originalImage] as? UIImage) { MediaHelper.askImageQuality(controller: self, forceQualityQuestion: self.askMediaQuality, { result in self.askMediaQuality = false; switch result { case .success(let quality): MediaHelper.compressImage(image: image, fileInfo: ShareFileInfo(filename: UUID().uuidString, suffix: "jpg"), quality: quality, completionHandler: { result in switch result { case .success((let fileUrl, let fileInfo)): self.uploadFile(url: fileUrl, filename: fileInfo.filenameWithSuffix, deleteSource: true); case .failure(let error): self.showAlert(shareError: error); } }) case .failure(_): return; } }); } picker.dismiss(animated: true, completion: nil); } func upload(imageUrl url: URL, fileInfo: ShareFileInfo) { MediaHelper.askImageQuality(controller: self, forceQualityQuestion: self.askMediaQuality, { result in self.askMediaQuality = false; switch result { case .success(let quality): MediaHelper.compressImage(url: url, fileInfo: fileInfo, quality: quality, completionHandler: { result in try? FileManager.default.removeItem(at: url); switch result { case .success((let fileUrl, let fileInfo)): self.uploadFile(url: fileUrl, filename: fileInfo.filenameWithSuffix, deleteSource: true); case .failure(let error): self.showAlert(shareError: error); } }) case .failure(_): return; } }); } func upload(movieUrl url: URL, fileInfo: ShareFileInfo) { MediaHelper.askVideoQuality(controller: self, forceQualityQuestion: self.askMediaQuality, { result in self.askMediaQuality = false; switch result { case .success(let quality): DispatchQueue.main.async { self.showProgressBar(); } MediaHelper.compressMovie(url: url, fileInfo: fileInfo, quality: quality, progressCallback: { [weak self] progress in DispatchQueue.main.async { self?.progressBar?.progress = progress; } }, completionHandler: { result in try? FileManager.default.removeItem(at: url); DispatchQueue.main.async { self.hideProgressBar(); } switch result { case .success((let fileUrl, let fileInfo)): self.uploadFile(url: fileUrl, filename: fileInfo.filenameWithSuffix, deleteSource: true); case .failure(let error): self.showAlert(error: error); } }) case .failure(_): return; } }); } private func copyFileLocally(url: URL) -> URL? { let filename = url.lastPathComponent; var suffix: String = ""; if let idx = filename.lastIndex(of: ".") { suffix = String(filename.suffix(from: idx)); } let tmpUrl = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + suffix, isDirectory: false); do { try FileManager.default.copyItem(at: url, to: tmpUrl); } catch { return nil; } return tmpUrl; } private func uploadFile(url fileUrl: URL, filename: String, deleteSource: Bool) { self.share(filename: filename, url: fileUrl, completionHandler: { result in switch result { case .success(let uploadedUrl, let filesize, let mimetype): var appendix = ChatAttachmentAppendix() appendix.filename = filename; appendix.filesize = filesize appendix.mimetype = mimetype; appendix.state = .downloaded; self.sendAttachment(originalUrl: fileUrl, uploadedUrl: uploadedUrl.absoluteString, appendix: appendix, completionHandler: { if deleteSource && FileManager.default.fileExists(atPath: fileUrl.path) { try? FileManager.default.removeItem(at: fileUrl); } }); case .failure(let error): self.showAlert(shareError: error); } }) } } ================================================ FILE: SiskinIM/chat/BaseChatViewController.swift ================================================ // // BaseChatViewController.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import UserNotifications import Martin import Combine class BaseChatViewController: UIViewController, UITextViewDelegate, ChatViewInputBarDelegate { @IBOutlet var containerView: UIView!; var conversationLogController: ConversationLogController? { didSet { self.conversationLogController?.conversation = self.conversation; } } @IBInspectable var animateScrollToBottom: Bool = true; var sendMessageButton: UIButton?; var conversation: Conversation! { didSet { conversationLogController?.conversation = conversation; } } private(set) var correctedMessageOriginId: String?; var progressBar: UIProgressView?; var askMediaQuality: Bool = false; var messageText: String? { get { return chatViewInputBar.text; } set { chatViewInputBar.text = newValue; if newValue == nil { self.correctedMessageOriginId = nil; } } } let chatViewInputBar = ChatViewInputBar(); private var cancellables: Set = []; func conversationTableViewDelegate() -> UITableViewDelegate? { return nil; } override func viewDidLoad() { super.viewDidLoad() chatViewInputBar.placeholder = String.localizedStringWithFormat(NSLocalizedString("from %@…", comment: "conversation view input field placeholder"), conversation.account.stringValue); navigationItem.leftBarButtonItem = splitViewController?.displayModeButtonItem; navigationItem.leftItemsSupplementBackButton = true; self.view.addSubview(chatViewInputBar); if let bottomTableViewConstraint = self.view.constraints.first(where: { $0.firstAnchor == containerView.bottomAnchor || $0.secondAnchor == containerView.bottomAnchor }) { bottomTableViewConstraint.isActive = false; self.view.removeConstraint(bottomTableViewConstraint); } NSLayoutConstraint.activate([ chatViewInputBar.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), chatViewInputBar.topAnchor.constraint(equalTo: containerView.bottomAnchor), chatViewInputBar.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), chatViewInputBar.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) ]); chatViewInputBar.setNeedsLayout(); chatViewInputBar.delegate = self; let sendMessageButton = UIButton(type: .custom); sendMessageButton.contentEdgeInsets = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4); sendMessageButton.setImage(UIImage(systemName: "paperplane.fill"), for: .normal); sendMessageButton.addTarget(self, action: #selector(sendMessageClicked(_:)), for: .touchUpInside); sendMessageButton.contentMode = .scaleToFill; sendMessageButton.tintColor = UIColor(named: "tintColor"); self.sendMessageButton = sendMessageButton; chatViewInputBar.addBottomButton(sendMessageButton); let locationButton = UIButton(type: .custom); locationButton.contentEdgeInsets = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4); locationButton.setImage(UIImage(systemName: "location"), for: .normal); locationButton.addTarget(self, action: #selector(sendMessageClicked(_:)), for: .touchUpInside); locationButton.contentMode = .scaleToFill; locationButton.tintColor = UIColor(named: "tintColor"); locationButton.addTarget(self, action: #selector(shareLocation(_:)), for: .touchUpInside); chatViewInputBar.addBottomButton(locationButton); setColors(); DBChatStore.instance.conversationsEventsPublisher.sink(receiveValue: { [weak self] event in switch event { case .destroyed(let conversation): DispatchQueue.main.async { self?.closed(conversation: conversation); } case .created(_): break; } }).store(in: &cancellables); } @objc func shareLocation(_ sender: Any) { let controller = ShareLocationController(); controller.conversation = self.conversation; let navController = UINavigationController(rootViewController: controller); navController.modalPresentationStyle = .pageSheet; self.present(navController, animated: true, completion: nil); } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if let destination = segue.destination as? ConversationLogController { self.conversationLogController = destination; } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); if self.messageText?.isEmpty ?? true { DBChatStore.instance.messageDraft(for: conversation.account, with: conversation.jid, completionHandler: { text in DispatchQueue.main.async { self.messageText = text; } }) } // chatViewInputBar.becomeFirstResponder(); NotificationCenter.default.addObserver(self, selector: #selector(ChatViewController.keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil); NotificationCenter.default.addObserver(self, selector: #selector(ChatViewController.keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil); animate(); } private func closed(conversation: Conversation) { guard self.conversation.id == conversation.id else { return; } if let navigationController = self.navigationController { if navigationController.viewControllers.count == 1 { self.showDetailViewController(UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "emptyDetailViewController"), sender: self); } else { navigationController.popToRootViewController(animated: true); } } else { self.dismiss(animated: true, completion: nil); } } private func animate() { guard let coordinator = self.transitionCoordinator else { return; } coordinator.animate(alongsideTransition: { [weak self] context in self?.setColors(); }, completion: nil); } private func setColors() { let appearance = UINavigationBarAppearance(); appearance.configureWithDefaultBackground(); appearance.backgroundEffect = UIBlurEffect.init(style: .systemMaterial); navigationController?.navigationBar.standardAppearance = appearance; navigationController?.navigationBar.scrollEdgeAppearance = appearance; navigationController?.navigationBar.barTintColor = UIColor.systemBackground; navigationController?.navigationBar.tintColor = UIColor(named: "tintColor"); } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated); } override func viewWillDisappear(_ animated: Bool) { NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil); NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil); super.viewWillDisappear(animated); DBChatStore.instance.storeMessage(draft: messageText, for: conversation.account, with: conversation.jid); } override func viewDidDisappear(_ animated: Bool) { //NotificationCenter.default.removeObserver(self); super.viewDidDisappear(animated); } @objc func keyboardWillShow(_ notification: NSNotification) { if let userInfo = notification.userInfo { if let endRect = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue { guard endRect.height != 0 && endRect.size.width != 0 else { return; } let window: UIView? = self.view.window; let keyboard = self.view.convert(endRect, from: window); let height = self.view.frame.size.height; let hasExternal = (keyboard.origin.y + keyboard.size.height) > height; let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as! TimeInterval; let curve = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as! UInt; UIView.animate(withDuration: duration, delay: 0.0, options: [UIView.AnimationOptions(rawValue: curve), UIView.AnimationOptions.beginFromCurrentState], animations: { if !hasExternal { self.keyboardHeight = endRect.origin.y == 0 ? endRect.size.width : endRect.size.height; } else { self.keyboardHeight = height - keyboard.origin.y; } }, completion: nil); } } } @objc func keyboardWillHide(_ notification: NSNotification) { let curve = notification.userInfo![UIResponder.keyboardAnimationCurveUserInfoKey] as! UInt; UIView.animate(withDuration: notification.userInfo![UIResponder.keyboardAnimationDurationUserInfoKey] as! TimeInterval, delay: 0.0, options: [UIView.AnimationOptions(rawValue: curve), UIView.AnimationOptions.beginFromCurrentState], animations: { self.keyboardHeight = 0; }, completion: nil); } var keyboardHeight: CGFloat = 0 { didSet { self.view.constraints.first(where: { $0.firstAnchor == self.view.bottomAnchor || $0.secondAnchor == self.view.bottomAnchor })?.constant = keyboardHeight * -1; } } @IBAction func tableViewClicked(_ sender: AnyObject) { _ = self.chatViewInputBar.resignFirstResponder(); } func startMessageCorrection(message: String, originId: String) { self.messageText = message; self.correctedMessageOriginId = originId; } func sendMessage() { assert(false, "This method should be overridden"); } func sendAttachment(originalUrl: URL?, uploadedUrl: String, appendix: ChatAttachmentAppendix, completionHandler: (() -> Void)?) { assert(false, "This method should be overridden"); } func messageTextCleared() { self.correctedMessageOriginId = nil; } @objc func sendMessageClicked(_ sender: Any) { self.sendMessage(); } } ================================================ FILE: SiskinIM/chat/BaseChatViewControllerWithDataSource.swift ================================================ // // BaseChatViewControllerWithDataSource.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class BaseChatViewControllerWithDataSource: BaseChatViewController, ConversationLogDelegate { private(set) var dataSource: ConversationDataSource!; override var conversationLogController: ConversationLogController? { didSet { dataSource = conversationLogController?.dataSource; conversationLogController?.conversationLogDelegate = self; } } func initialize(tableView: UITableView) { } } ================================================ FILE: SiskinIM/chat/BaseChatViewControllerWithDataSourceContextMenuAndToolbar.swift ================================================ // // BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar: BaseChatViewControllerWithDataSource, UITableViewDelegate { fileprivate weak var timestampsSwitch: UIBarButtonItem? = nil; var contextActions: [ContextAction] = [.showMap, .copy, .reply, .share, .report, .correct, .retract, .moderate, .more]; override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); } override func viewDidDisappear(_ animated: Bool) { UIMenuController.shared.menuItems = UIMenuController.shared.menuItems?.filter({ it -> Bool in it.action != #selector(ChatTableViewCell.actionMore(_:))}); super.viewDidDisappear(animated); } override func initialize(tableView: UITableView) { super.initialize(tableView: tableView); tableView.delegate = self; } func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { let cell = self.conversationLogController!.tableView(tableView, cellForRowAt: indexPath); cell.contentView.transform = .identity; let view = UIViewController(); let size = self.conversationLogController!.tableView.rectForRow(at: indexPath).size; view.view = cell.contentView; view.preferredContentSize = size; return view; }) { suggestedActions -> UIMenu? in return self.prepareContextMenu(for: indexPath); }; } func prepareContextMenu(for indexPath: IndexPath) -> UIMenu? { guard let item = self.conversationLogController!.dataSource.getItem(at: indexPath.row) else { return nil; } let actions = self.contextActions.filter({ self.canExecuteContext(action: $0, forItem: item, at: indexPath) }); let items: [UIMenuElement] = actions.map({ action -> UIMenuElement in if action.isDesctructive { return UIMenu(title: action.title, image: action.image, options: .destructive, children: [ UIAction(title: NSLocalizedString("No", comment: "context menu action"), handler: { _ in }), UIAction(title: NSLocalizedString("Yes", comment: "context menu action"), attributes: .destructive, handler: { _ in self.executeContext(action: action, forItem: item, at: indexPath); }) ]); } else { switch action { case .report: return UIMenu(title: action.title, image: action.image, children: [ UIAction(title: NSLocalizedString("Report spam", comment: "context menu action"), attributes: .destructive, handler: { _ in self.conversation.context?.module(.blockingCommand).block(jid: JID(self.conversation.jid), report: .init(cause: .spam), completionHandler: { _ in }); }), UIAction(title: NSLocalizedString("Report abuse", comment: "context menu action"), attributes: .destructive, handler: { _ in self.conversation.context?.module(.blockingCommand).block(jid: JID(self.conversation.jid), report: .init(cause: .abuse), completionHandler: { _ in }); }), UIAction(title: NSLocalizedString("Block server", comment: "context menu action - block communication with server"), attributes: [.destructive], handler: { _ in self.conversation.context?.module(.blockingCommand).block(jid: JID(self.conversation.jid.domain), completionHandler: { _ in }); }), UIAction(title: NSLocalizedString("Cancel", comment: "context menu action"), handler: { _ in }) ]) default: return UIAction(title: action.title, image: action.image, handler: { _ in self.executeContext(action: action, forItem: item, at: indexPath); }) } } }) return UIMenu(title: "", children: items); } public func executeContext(action: ContextAction, forItem item: ConversationEntry, at indexPath: IndexPath) { switch action { case .showMap: self.conversationLogController?.showMap(item: item); case .copy: self.conversationLogController?.copyMessageInt(paths: [indexPath]); case .reply: // something to do.. self.conversationLogController?.getTextOfSelectedRows(paths: [indexPath], withTimestamps: false, handler: { [weak self] texts in let text: String = texts.flatMap { $0.split(separator: "\n")}.map { if $0.starts(with: ">") { return ">\($0)"; } else { return "> \($0)" } }.joined(separator: "\n"); if let current = self?.messageText, !current.isEmpty { self?.messageText = "\(current)\n\(text)\n"; } else { self?.messageText = "\(text)\n"; } }) case .share: self.conversationLogController?.shareMessageInt(paths: [indexPath]); case .correct: if case .message(let message, _) = item.payload { DBChatHistoryStore.instance.originId(for: item.conversation, id: item.id, completionHandler: { [weak self] originId in DispatchQueue.main.async { self?.startMessageCorrection(message: message, originId: originId) } }); } case .retract, .moderate: // that is per-chat-type sepecific break; case .more: guard let cell = self.conversationLogController?.tableView.cellForRow(at: indexPath) else { return; } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { NotificationCenter.default.post(name: Notification.Name("tableViewCellShowEditToolbar"), object: cell); } case .report: // taken care of in `prepareContextMenu(for:)` break; } } public func canExecuteContext(action: ContextAction, forItem item: ConversationEntry, at indexPath: IndexPath) -> Bool { switch action { case .showMap: guard case .location(_) = item.payload else { return false; } return true; case .copy: return true; case .reply: return true; case .report: return false; case .share: return true; case .correct: if item.state.direction == .outgoing, case .message(_,_) = item.payload, !dataSource.isAnyMatching({ it in if it.state.direction == .outgoing, case .message(_,_) = it.payload { return true; } else { return false; } }, in: 0.. UIContextMenuConfiguration? { return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { suggestedActions in return self.prepareContextMenu(); }) } func prepareContextMenu() -> UIMenu { guard let item = self.item, case .attachment(let url, _) = item.payload else { return UIMenu(title: ""); } if let localUrl = DownloadStore.instance.url(for: "\(item.id)") { let items = [ UIAction(title: NSLocalizedString("Preview", comment: "context action"), image: UIImage(systemName: "eye.fill"), handler: { action in self.open(url: localUrl, preview: true); }), UIAction(title: NSLocalizedString("Copy", comment: "context action"), image: UIImage(systemName: "doc.on.doc"), handler: { action in UIPasteboard.general.strings = [url]; UIPasteboard.general.string = url; }), UIAction(title: NSLocalizedString("Share…", comment: "context action"), image: UIImage(systemName: "square.and.arrow.up"), handler: { action in self.open(url: localUrl, preview: false); }), UIAction(title: NSLocalizedString("Delete", comment: "context action"), image: UIImage(systemName: "trash"), attributes: [.destructive], handler: { action in DownloadStore.instance.deleteFile(for: "\(item.id)"); DBChatHistoryStore.instance.updateItem(for: item.conversation, id: item.id, updateAppendix: { appendix in appendix.state = .removed; }) }) ]; return UIMenu(title: localUrl.lastPathComponent, image: nil, identifier: nil, options: [], children: items); } else { return UIMenu(title: ""); } } var documentController: UIDocumentInteractionController?; func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController { let viewController = ((UIApplication.shared.delegate as? AppDelegate)?.window?.rootViewController?.presentedViewController)!; return viewController; } func open(url: URL, preview: Bool) { let documentController = UIDocumentInteractionController(url: url); documentController.delegate = self; documentController.name = url.lastPathComponent; if preview && documentController.presentPreview(animated: true) { self.documentController = documentController; } else if documentController.presentOptionsMenu(from: self.superview?.convert(self.frame, to: self.superview?.superview) ?? CGRect.zero, in: self, animated: true) { self.documentController = documentController; } } } ================================================ FILE: SiskinIM/chat/ChatAttachementsController.swift ================================================ // // ChatAttachementsController.swift // Siskin IM // // Created by Andrzej Wójcik on 03/01/2020. // Copyright © 2020 Tigase, Inc. All rights reserved. // import UIKit import Martin import Combine class ChatAttachmentsController: UICollectionViewController, UICollectionViewDelegateFlowLayout { private var items: [ConversationEntry] = []; var conversation: Conversation!; private var loaded: Bool = false; private var cancellables: Set = []; override func viewDidLoad() { super.viewDidLoad(); } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); let conversation = self.conversation!; DBChatHistoryStore.instance.events.compactMap({ it -> ConversationEntry? in if case .updated(let item) = it { return item; } return nil; }).filter({ item in if case .attachment(_, _) = item.payload, item.conversation.account == conversation.account && item.conversation.jid == conversation.jid { return true; } return false; }).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] value in if let idx = self?.items.firstIndex(where: { $0.id == value.id }) { self?.items[idx] = value; self?.collectionView.reloadItems(at: [IndexPath(row: idx, section: 0)]); } }).store(in: &cancellables); if !loaded { self.loaded = true; DBChatHistoryStore.instance.loadAttachments(for: conversation, completionHandler: { attachments in DispatchQueue.main.async { self.items = attachments.filter({ (attachment) -> Bool in return DownloadStore.instance.url(for: "\(attachment.id)") != nil; }); self.collectionView.reloadData(); } }); } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated); } override func numberOfSections(in collectionView: UICollectionView) -> Int { return 1; } override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { if items.isEmpty { if self.collectionView.backgroundView == nil { let label = UILabel(frame: CGRect(x: 0, y:0, width: self.view.bounds.size.width, height: self.view.bounds.size.height)); label.text = NSLocalizedString("No attachments", comment: "attachments view label"); label.font = UIFont.systemFont(ofSize: UIFont.systemFontSize + 2, weight: .medium); label.numberOfLines = 0; label.textAlignment = .center; label.sizeToFit(); self.collectionView.backgroundView = label; } } else { self.collectionView.backgroundView = nil; } return items.count; } override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = self.collectionView.dequeueReusableCell(withReuseIdentifier: "AttachmentCellView", for: indexPath) as! ChatAttachmentsCellView; cell.set(item: items[indexPath.item]); return cell; } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { let width = (self.view.bounds.width - 2 * 2.0) / 3.0; return CGSize(width: width, height: width); } } ================================================ FILE: SiskinIM/chat/ChatViewController.swift ================================================ // // ChatViewController.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Shared import Martin import MartinOMEMO import Combine class ChatViewController : BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar { var chat: Chat { return conversation as! Chat; } var titleView: ChatTitleView! { get { return (self.navigationItem.titleView as! ChatTitleView); } } private var cancellables: Set = []; override func conversationTableViewDelegate() -> UITableViewDelegate? { return self; } override func viewDidLoad() { super.viewDidLoad() let recognizer = UITapGestureRecognizer(target: self, action: #selector(ChatViewController.showBuddyInfo)); self.titleView.isUserInteractionEnabled = true; self.navigationController?.navigationBar.addGestureRecognizer(recognizer); initializeSharing(); } @objc func showBuddyInfo(_ button: Any) { let navigation = storyboard?.instantiateViewController(withIdentifier: "ContactViewNavigationController") as! UINavigationController; let contactView = navigation.visibleViewController as! ContactViewController; contactView.account = conversation.account; contactView.jid = conversation.jid; contactView.chat = self.chat; //contactView.showEncryption = true; navigation.title = self.navigationItem.title; navigation.modalPresentationStyle = .formSheet; self.present(navigation, animated: true, completion: nil); } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); if CallManager.isAvailable { var buttons: [UIBarButtonItem] = []; buttons.append(self.smallBarButtinItem(image: UIImage(named: "videoCall")!, action: #selector(self.videoCall))); buttons.append(self.smallBarButtinItem(image: UIImage(named: "audioCall")!, action: #selector(self.audioCall))); self.navigationItem.rightBarButtonItems = buttons; } conversation.context?.$state.map({ $0 == .connected() }).receive(on: DispatchQueue.main).assign(to: \.connected, on: self.titleView).store(in: &cancellables); conversation.displayNamePublisher.map({ $0 }).assign(to: \.name, on: self.titleView).store(in: &cancellables); let jid = JID(chat.jid); conversation.statusPublisher.combineLatest(conversation.descriptionPublisher, chat.optionsPublisher, conversation.context!.module(.blockingCommand).$blockedJids).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] (show, description, options, blockedJids) in let isBlocked = (blockedJids?.contains(jid) ?? false || blockedJids?.contains(JID(jid.domain)) ?? false); self?.titleView.setStatus(show, description: description, encryption: options.encryption, isBlocked: isBlocked); }).store(in: &cancellables) } override func viewDidDisappear(_ animated: Bool) { //NotificationCenter.default.removeObserver(self); cancellables.removeAll(); super.viewDidDisappear(animated); } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) { guard let item = dataSource.getItem(at: indexPath.row) else { return; } let alert = UIAlertController(title: NSLocalizedString("Details", comment: "alert title"), message: item.state.errorMessage ?? NSLocalizedString("Unknown error occurred", comment: "alert body"), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("Resend", comment: "button label"), style: .default, handler: {(action) in switch item.payload { case .message(let message, _): self.chat.sendMessage(text: message, correctedMessageOriginId: nil); DBChatHistoryStore.instance.remove(item: item); case .attachment(let url, let appendix): let oldLocalFile = DownloadStore.instance.url(for: "\(item.id)"); self.chat.sendAttachment(url: url, appendix: appendix, originalUrl: oldLocalFile, completionHandler: { DBChatHistoryStore.instance.remove(item: item); }); default: break; } })); alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); self.present(alert, animated: true, completion: nil); } override func canExecuteContext(action: BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar.ContextAction, forItem item: ConversationEntry, at indexPath: IndexPath) -> Bool { switch action { case .retract: return item.state.direction == .outgoing && XmppService.instance.getClient(for: item.conversation.account)?.isConnected ?? false; case .report: return item.state.direction == .incoming && XmppService.instance.getClient(for: item.conversation.account)?.module(.blockingCommand).isReportingSupported ?? false; default: return super.canExecuteContext(action: action, forItem: item, at: indexPath); } } override func executeContext(action: BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar.ContextAction, forItem item: ConversationEntry, at indexPath: IndexPath) { switch action { case .retract: guard item.state.direction == .outgoing else { return; } chat.retract(entry: item) default: super.executeContext(action: action, forItem: item, at: indexPath); } } fileprivate func smallBarButtinItem(image: UIImage, action: Selector) -> UIBarButtonItem { let btn = UIButton(type: .custom); btn.setImage(image, for: .normal); btn.addTarget(self, action: action, for: .touchUpInside); btn.frame = CGRect(x: 0, y: 0, width: 30, height: 30); return UIBarButtonItem(customView: btn); } @objc func audioCall() { VideoCallController.call(jid: self.conversation.jid, from: self.conversation.account, media: [.audio], sender: self); } @objc func videoCall() { VideoCallController.call(jid: self.conversation.jid, from: self.conversation.account, media: [.audio, .video], sender: self); } @IBAction func sendClicked(_ sender: UIButton) { sendMessage(); } override func sendMessage() { guard let text = messageText, !text.isEmpty else { return; } chat.sendMessage(text: text, correctedMessageOriginId: self.correctedMessageOriginId) DispatchQueue.main.async { self.messageText = nil; } } override func sendAttachment(originalUrl: URL?, uploadedUrl: String, appendix: ChatAttachmentAppendix, completionHandler: (() -> Void)?) { chat.sendAttachment(url: uploadedUrl, appendix: appendix, originalUrl: originalUrl, completionHandler: completionHandler); } } class BaseConversationTitleView: UIView { @IBOutlet var nameView: UILabel!; @IBOutlet var statusView: UILabel!; override var intrinsicContentSize: CGSize { return UIView.layoutFittingExpandedSize } } class ChatTitleView: BaseConversationTitleView { var encryption: ChatEncryption? = nil; var name: String? { get { return nameView.text; } set { nameView.text = newValue; } } var connected: Bool = false { didSet { guard oldValue != connected else { return; } refresh(); } } // var status: Presence? { // didSet { // self.refresh(); // } // } private var statusShow: Presence.Show? = nil; private var statusDescription: String? = nil; private var isBlocked: Bool = false; func setStatus(_ show: Presence.Show?, description: String?, encryption: ChatEncryption?, isBlocked: Bool) { statusShow = show; statusDescription = description; self.encryption = encryption; self.isBlocked = isBlocked; refresh(); } override func didMoveToSuperview() { super.didMoveToSuperview(); if let superview = self.superview { NSLayoutConstraint.activate([ self.widthAnchor.constraint(lessThanOrEqualTo: superview.widthAnchor, multiplier: 0.6)]); } } // func reload(for account: BareJID, with jid: BareJID) { // if let rosterModule: RosterModule = XmppService.instance.getClient(for: account)?.modulesManager.getModule(RosterModule.ID) { // self.name = rosterModule.rosterStore.get(for: JID(jid))?.name ?? jid.stringValue; // } else { // self.name = jid.stringValue; // } // self.encryption = (DBChatStore.instance.getChat(for: account, with: jid) as? DBChat)?.options.encryption; // } fileprivate func refresh() { DispatchQueue.main.async { let encryption = self.encryption ?? Settings.messageEncryption; if self.connected { let statusIcon = NSTextAttachment(); statusIcon.image = self.isBlocked ? UIImage(systemName: "hand.raised")?.withTintColor(UIColor.systemRed) : AvatarStatusView.getStatusImage(self.statusShow); let height = self.statusView.font.pointSize; statusIcon.bounds = CGRect(x: 0, y: -2, width: height, height: height); if self.isBlocked { let statusText = NSMutableAttributedString(attachment: statusIcon); statusText.append(NSAttributedString(string: NSLocalizedString("Blocked", comment: "user status - contact blocked"))); self.statusView.attributedText = statusText; } else { var desc = self.statusDescription; if desc == nil { let show = self.statusShow; if show == nil { desc = NSLocalizedString("Offline", comment: "user status"); } else { switch(show!) { case .online: desc = NSLocalizedString("Online", comment: "user status"); case .chat: desc = NSLocalizedString("Free for chat", comment: "user status"); case .away: desc = NSLocalizedString("Be right back", comment: "user status"); case .xa: desc = NSLocalizedString("Away", comment: "user status"); case .dnd: desc = NSLocalizedString("Do not disturb", comment: "user status"); } } } let statusText = NSMutableAttributedString(string: encryption == .none ? "" : "\u{1F512} "); statusText.append(NSAttributedString(attachment: statusIcon)); statusText.append(NSAttributedString(string: desc!)); self.statusView.attributedText = statusText; } } else { switch encryption { case .omemo: self.statusView.text = "\u{1F512} \u{26A0} \(NSLocalizedString("Not connected", comment: "channel status label"))!"; case .none: self.statusView.text = "\u{26A0} \(NSLocalizedString("Not connected", comment: "channel status label"))!"; } } } } } ================================================ FILE: SiskinIM/chat/ChatViewInputBar.swift ================================================ // // ChatViewInputBar.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class ChatViewInputBar: UIView, UITextViewDelegate, NSTextStorageDelegate { public let blurView: UIVisualEffectView = { let blurEffect = UIBlurEffect(style: .systemMaterial); let view = UIVisualEffectView(effect: blurEffect); view.translatesAutoresizingMaskIntoConstraints = false; return view; }(); public let bottomStackView: UIStackView = { let view = UIStackView(); view.translatesAutoresizingMaskIntoConstraints = false; view.axis = .horizontal; view.alignment = .trailing; view.semanticContentAttribute = .forceRightToLeft; // view.distribution = .fillEqually; view.spacing = 16; view.setContentHuggingPriority(.defaultHigh, for: .horizontal); view.setContentCompressionResistancePriority(.defaultHigh, for: .vertical); return view; }(); public let inputTextView: UITextView = { let layoutManager = MessageTextView.CustomLayoutManager(); let textContainer = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude)); textContainer.widthTracksTextView = true; let textStorage = NSTextStorage(); textStorage.addLayoutManager(layoutManager); layoutManager.addTextContainer(textContainer); let view = UITextView(frame: .zero, textContainer: textContainer); view.isOpaque = false; view.backgroundColor = UIColor.clear; view.translatesAutoresizingMaskIntoConstraints = false; view.layer.masksToBounds = true; // view.delegate = self; view.isScrollEnabled = false; view.usesStandardTextScaling = false; view.font = Markdown.font(withTextStyle: .body, andTraits: []); if Settings.sendMessageOnReturn { view.returnKeyType = .send; } else { view.returnKeyType = .default; } view.setContentHuggingPriority(.defaultHigh, for: .horizontal); view.setContentCompressionResistancePriority(.defaultHigh, for: .vertical); return view; }() public let voiceRecordingView: VoiceRecordingView = { return VoiceRecordingView(); }(); public let placeholderLabel: UILabel = { let view = UILabel(); view.numberOfLines = 0; view.textColor = UIColor.secondaryLabel; view.font = Markdown.font(withTextStyle: .body, andTraits: []); view.text = NSLocalizedString("Enter message…", comment: "placeholder"); view.backgroundColor = .clear; view.translatesAutoresizingMaskIntoConstraints = false; return view; }(); var placeholder: String? { get { return placeholderLabel.text; } set { placeholderLabel.text = newValue; } } var text: String? { get { return inputTextView.text; } set { inputTextView.text = newValue ?? ""; placeholderLabel.isHidden = !inputTextView.text.isEmpty; } } weak var delegate: ChatViewInputBarDelegate?; convenience init() { self.init(frame: CGRect(origin: .zero, size: CGSize(width: 100, height: 30))); } override init(frame: CGRect) { super.init(frame: frame); self.setup() } required init?(coder: NSCoder) { super.init(coder: coder); setup(); } func setup() { inputTextView.textStorage.delegate = self; translatesAutoresizingMaskIntoConstraints = false; isOpaque = false; setContentHuggingPriority(.defaultHigh, for: .horizontal); setContentCompressionResistancePriority(.defaultHigh, for: .vertical); addSubview(blurView); addSubview(inputTextView); addSubview(bottomStackView); NSLayoutConstraint.activate([ blurView.leadingAnchor.constraint(equalTo: self.leadingAnchor), blurView.trailingAnchor.constraint(equalTo: self.trailingAnchor), blurView.topAnchor.constraint(equalTo: self.topAnchor), blurView.bottomAnchor.constraint(equalTo: self.bottomAnchor), inputTextView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 6), inputTextView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -6), inputTextView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), inputTextView.bottomAnchor.constraint(equalTo: bottomStackView.topAnchor), bottomStackView.leadingAnchor.constraint(greaterThanOrEqualTo: safeAreaLayoutGuide.leadingAnchor, constant: 10), bottomStackView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -10), bottomStackView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: 0) ]); inputTextView.addSubview(placeholderLabel); NSLayoutConstraint.activate([ inputTextView.leadingAnchor.constraint(equalTo: placeholderLabel.leadingAnchor, constant: -4), inputTextView.trailingAnchor.constraint(equalTo: placeholderLabel.trailingAnchor, constant: 4), inputTextView.centerYAnchor.constraint(equalTo: placeholderLabel.centerYAnchor), inputTextView.topAnchor.constraint(equalTo: placeholderLabel.topAnchor), inputTextView.bottomAnchor.constraint(equalTo: placeholderLabel.bottomAnchor) ]); inputTextView.delegate = self; } @objc func startRecordingVoiceMessage(_ sender: Any) { UIView.animate(withDuration: 0.3, animations: { self.addSubview(self.voiceRecordingView); NSLayoutConstraint.activate([ self.leadingAnchor.constraint(equalTo: self.voiceRecordingView.leadingAnchor), self.trailingAnchor.constraint(equalTo: self.voiceRecordingView.trailingAnchor), self.bottomAnchor.constraint(equalTo: self.voiceRecordingView.bottomAnchor), self.topAnchor.constraint(equalTo: self.voiceRecordingView.topAnchor) ]) }, completion: { _ in self.voiceRecordingView.startRecording(); }) } override func layoutIfNeeded() { super.layoutIfNeeded(); inputTextView.layoutIfNeeded(); } override func resignFirstResponder() -> Bool { let val = super.resignFirstResponder(); return val || inputTextView.resignFirstResponder(); } func textViewDidChange(_ textView: UITextView) { placeholderLabel.isHidden = textView.hasText; } func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { if text == "\n" { if inputTextView.returnKeyType == .send { delegate?.sendMessage(); return false; } } if textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { delegate?.messageTextCleared(); } return true; } func textViewDidEndEditing(_ textView: UITextView) { textView.resignFirstResponder(); } func addBottomButton(_ button: UIButton) { bottomStackView.addArrangedSubview(button); } func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) { let fullRange = NSRange(0.. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import UIKit import MapKit import CoreLocationUI import CoreLocation class ShareLocationController: UIViewController, MKMapViewDelegate, CLLocationManagerDelegate { private let searchResultsController = ShareLocationSearchResultsController(); private let mapView = MKMapView(); private let locationManager = CLLocationManager(); private let currentAnnotation = MKPointAnnotation(); var conversation: Conversation!; private var activityIndicator: UIActivityIndicatorView?; override func viewDidLoad() { self.title = NSLocalizedString("Select location", comment: "location selection window title"); self.view = mapView; super.viewDidLoad(); mapView.delegate = self; let navAppearance = UINavigationBarAppearance(); navAppearance.configureWithDefaultBackground(); navAppearance.backgroundEffect = UIBlurEffect(style: .regular); self.navigationController?.navigationBar.standardAppearance = navAppearance; self.navigationController?.navigationBar.scrollEdgeAppearance = navAppearance; self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(dismissView)) self.navigationItem.searchController = UISearchController(searchResultsController: searchResultsController); self.navigationItem.searchController?.hidesNavigationBarDuringPresentation = false; self.navigationItem.searchController?.searchBar.placeholder = NSLocalizedString("Search for places", comment: "placeholder for location selection search bar"); self.navigationItem.searchController?.searchResultsUpdater = searchResultsController; self.definesPresentationContext = true; self.searchResultsController.mapView = mapView; self.searchResultsController.mapController = self; if #available(iOS 15, *) { let locationButton = CLLocationButton(frame: .init(origin: .zero, size: CGSize(width: 100, height: 100))); locationButton.translatesAutoresizingMaskIntoConstraints = false; locationButton.backgroundColor = UIColor(named: "tintColor"); locationButton.tintColor = .systemBackground; locationButton.tintAdjustmentMode = .dimmed; locationButton.label = .none; locationButton.icon = .arrowFilled; locationButton.fontSize = 24; locationButton.cornerRadius = 32; locationButton.isOpaque = false; self.view.addSubview(locationButton) locationButton.addTarget(self, action: #selector(requestCurrentLocationiOS15), for: .touchUpInside); NSLayoutConstraint.activate([ view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: locationButton.bottomAnchor, constant: 20), view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: locationButton.trailingAnchor, constant: 20) ]); } else { let locationButton = RoundButton(type: .custom); locationButton.translatesAutoresizingMaskIntoConstraints = false; locationButton.setImage(UIImage(systemName: "location.fill"), for: .normal); locationButton.backgroundColor = UIColor(named: "tintColor"); locationButton.tintColor = .systemBackground; locationButton.isOpaque = true; self.view.addSubview(locationButton) locationButton.addTarget(self, action: #selector(requestCurrentLocationPreiOS15(_:)), for: .touchUpInside); NSLayoutConstraint.activate([ view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: locationButton.bottomAnchor, constant: 20), view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: locationButton.trailingAnchor, constant: 20), locationButton.widthAnchor.constraint(equalTo: locationButton.heightAnchor), locationButton.heightAnchor.constraint(equalToConstant: 40) ]); } let tapGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleTap(_:))); self.mapView.addGestureRecognizer(tapGesture); } @objc func dismissView() { self.navigationController?.dismiss(animated: true, completion: nil); } @objc func handleTap(_ sender: UILongPressGestureRecognizer) { guard sender.state == .ended else { return; } let coordinate = self.mapView.convert(sender.location(in: self.mapView), toCoordinateFrom: self.mapView); setCurrentLocation(CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude), zoomIn: false); } @available(iOS, obsoleted: 15, message: "We are using CLLocationButton now!") @objc func requestCurrentLocationPreiOS15(_ sender: UIButton) { if CLLocationManager.authorizationStatus() == .authorizedWhenInUse || CLLocationManager.authorizationStatus() == .authorizedAlways { requestCurrentLocation(); } else { locationManager.delegate = self; locationManager.requestWhenInUseAuthorization(); } } @available(iOS 15.0, *) @objc func requestCurrentLocationiOS15() { if CLLocationManager.authorizationStatus() == .authorizedWhenInUse || CLLocationManager.authorizationStatus() == .authorizedAlways { requestCurrentLocation(); } else { locationManager.delegate = self; } } private func requestCurrentLocation() { if activityIndicator == nil { activityIndicator = UIActivityIndicatorView(style: .large); activityIndicator?.translatesAutoresizingMaskIntoConstraints = false; activityIndicator?.hidesWhenStopped = true; mapView.addSubview(activityIndicator!); NSLayoutConstraint.activate([mapView.centerXAnchor.constraint(equalTo: activityIndicator!.centerXAnchor), mapView.centerYAnchor.constraint(equalTo: activityIndicator!.centerYAnchor)]); } // locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters; locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters; locationManager.delegate = self; activityIndicator?.startAnimating(); locationManager.requestLocation(); } @available(iOS, obsoleted: 15, message: "We are using CLLocationButton now!") func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { if CLLocationManager.authorizationStatus() == .authorizedWhenInUse || CLLocationManager.authorizationStatus() == .authorizedAlways { requestCurrentLocation(); } } func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { let view = MKPinAnnotationView(annotation: annotation, reuseIdentifier: nil); view.isEnabled = true; view.isDraggable = true; view.canShowCallout = true; let accessory = UIButton(type: .custom); accessory.setImage(UIImage(systemName: "location.fill"), for: .normal); accessory.tintColor = UIColor(named: "tintColor"); accessory.frame = CGRect(origin: .zero, size: CGSize(width: 30, height: 30)); accessory.addTarget(self, action: #selector(shareSelectedLocation), for: .touchUpInside); view.rightCalloutAccessoryView = accessory; return view; } func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, didChange newState: MKAnnotationView.DragState, fromOldState oldState: MKAnnotationView.DragState) { if newState == .ending, let annotation = view.annotation { setCurrentLocation(CLLocation(latitude: annotation.coordinate.latitude, longitude: annotation.coordinate.longitude), zoomIn: false); } } @objc func shareSelectedLocation(_ sender: Any) { conversation.sendMessage(text: currentAnnotation.geoUri, correctedMessageOriginId: nil); dismissView(); } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { activityIndicator?.stopAnimating(); guard let location = locations.first else { return; } setCurrentLocation(location, zoomIn: true); } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { activityIndicator?.stopAnimating(); if #available(iOS 15.0, *), let err = error as? CLError, err.code == .denied { return; } let alert = UIAlertController(title: NSLocalizedString("Failure", comment: "alert window title"), message: error.localizedDescription, preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "action label"), style: .cancel, handler: nil)); self.present(alert, animated: true, completion: nil); } func setCurrentLocation(placemark place: CLPlacemark, coordinate: CLLocationCoordinate2D, zoomIn: Bool) { self.mapView.removeAnnotation(currentAnnotation); currentAnnotation.coordinate = coordinate; let address = [place.name, place.thoroughfare, place.locality, place.subLocality, place.administrativeArea, place.postalCode, place.country].compactMap({ $0 }); if address.isEmpty { self.currentAnnotation.title = NSLocalizedString("Your location", comment: "search location pin label"); } else { self.currentAnnotation.title = address.joined(separator: ", "); } DispatchQueue.main.async { self.mapView.addAnnotation(self.currentAnnotation); if zoomIn { self.mapView.setRegion(MKCoordinateRegion(center: coordinate, latitudinalMeters: 2000, longitudinalMeters: 2000), animated: true); } else { self.mapView.centerCoordinate = coordinate; } self.mapView.selectAnnotation(self.currentAnnotation, animated: true); } } private func setCurrentLocation(_ location: CLLocation, zoomIn: Bool) { self.mapView.removeAnnotation(currentAnnotation); let geocoder = CLGeocoder(); geocoder.reverseGeocodeLocation(location, completionHandler: { (places, error) in guard error == nil, let place = places?.first else { return; } self.setCurrentLocation(placemark: place, coordinate: location.coordinate, zoomIn: zoomIn); }) } } extension MKPointAnnotation { var geoUri: String { return coordinate.geoUri; } } extension CLLocationCoordinate2D { public static let geoRegex = try! NSRegularExpression(pattern: "geo:\\-?[0-9]+\\.?[0-9]*,\\-?[0-9]+\\.?[0-9]*"); public var geoUri: String { return "geo:\(self.latitude),\(self.longitude)"; } public init?(geoUri: String) { guard geoUri.starts(with: "geo:"), !CLLocationCoordinate2D.geoRegex.matches(in: geoUri, options: [], range: NSRange(location: 0, length: geoUri.count)).isEmpty else { return nil; } let parts = geoUri.dropFirst(4).split(separator: ",").compactMap({ Double(String($0)) }); guard parts.count == 2 else { return nil; } self.init(latitude: parts[0], longitude: parts[1]); } } extension CLLocationCoordinate2D: Hashable { public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool { return lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude; } public func hash(into hasher: inout Hasher) { hasher.combine(self.latitude); hasher.combine(self.longitude); } } ================================================ FILE: SiskinIM/chat/ShareLocationSearchResultsController.swift ================================================ // // ShareLocationSearchResultsController.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import UIKit import MapKit class ShareLocationSearchResultsController: UITableViewController, UISearchResultsUpdating { var mapView: MKMapView!; weak var mapController: ShareLocationController!; private var matchingItems: [MKMapItem] = []; private var id = UUID(); func updateSearchResults(for searchController: UISearchController) { guard let query = searchController.searchBar.text else { return; } let id = UUID(); self.id = id; let request = MKLocalSearch.Request(); request.naturalLanguageQuery = query; request.region = mapView.region; let search = MKLocalSearch(request: request); search.start(completionHandler: { (response, _) in guard let response = response, self.id == id else { return; } self.matchingItems = response.mapItems; self.tableView.reloadData(); }) } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return matchingItems.count; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil); let item = matchingItems[indexPath.row].placemark; cell.textLabel?.text = item.name; let address = [item.thoroughfare, item.locality, item.subLocality, item.administrativeArea, item.postalCode, item.country]; cell.detailTextLabel?.text = address.compactMap({ $0 }).joined(separator: ", "); return cell; } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let item = matchingItems[indexPath.row].placemark; mapController.setCurrentLocation(placemark: item, coordinate: item.coordinate, zoomIn: true); self.dismiss(animated: true, completion: nil); } } ================================================ FILE: SiskinIM/chats/ChatsListTableViewCell.swift ================================================ // // ChatsListTableViewCell.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine class ChatsListTableViewCell: UITableViewCell { private static let throttlingQueue = DispatchQueue(label: "ChatCellViewThrottlingQueue"); private static let relativeForamtter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter(); formatter.dateTimeStyle = .named; formatter.unitsStyle = .short; return formatter; }(); fileprivate static let todaysFormatter = ({()-> DateFormatter in var f = DateFormatter(); f.dateStyle = .none; f.timeStyle = .short; return f; })(); fileprivate static let defaultFormatter = ({()-> DateFormatter in var f = DateFormatter(); f.dateFormat = DateFormatter.dateFormat(fromTemplate: "dd.MM", options: 0, locale: NSLocale.current); // f.timeStyle = .NoStyle; return f; })(); fileprivate static let fullFormatter = ({()-> DateFormatter in var f = DateFormatter(); f.dateFormat = DateFormatter.dateFormat(fromTemplate: "dd.MM.yyyy", options: 0, locale: NSLocale.current); // f.timeStyle = .NoStyle; return f; })(); private static func formatTimestamp(_ ts: Date, _ now: Date) -> String { let flags: Set = [.minute, .hour, .day, .year]; var components = Calendar.current.dateComponents(flags, from: now, to: ts); if (components.day! >= -1) { components.second = 0; return relativeForamtter.localizedString(from: components); } if (components.year! != 0) { return ChatsListTableViewCell.fullFormatter.string(from: ts); } else { return ChatsListTableViewCell.defaultFormatter.string(from: ts); } } // MARK: Properties @IBOutlet var nameLabel: UILabel! @IBOutlet var avatarStatusView: AvatarStatusView! { didSet { avatarStatusView?.backgroundColor = UIColor(named: "chatslistBackground"); } } @IBOutlet var lastMessageLabel: UILabel! @IBOutlet var timestampLabel: UILabel! @IBOutlet var badge: BadgeButton!; override var backgroundColor: UIColor? { get { return super.backgroundColor; } set { super.backgroundColor = UIColor(named: "chatslistBackground"); avatarStatusView?.backgroundColor = UIColor(named: "chatslistBackground"); } } private var cancellables: Set = []; private var conversation: Conversation? { didSet { cancellables.removeAll(); conversation?.displayNamePublisher.map({ $0 }).assign(to: \.text, on: nameLabel).store(in: &cancellables); avatarStatusView.displayableId = conversation; conversation?.unreadPublisher.throttleFixed(for: 0.1, scheduler: ChatsListTableViewCell.throttlingQueue, latest: true).removeDuplicates().receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] value in self?.set(unread: value); }).store(in: &cancellables); conversation?.timestampPublisher.throttleFixed(for: 0.1, scheduler: ChatsListTableViewCell.throttlingQueue, latest: true).combineLatest(CurrentTimePublisher.publisher).map({ (value, now) in ChatsListTableViewCell.formatTimestamp(value, now) }).receive(on: DispatchQueue.main).assign(to: \.text, on: timestampLabel).store(in: &cancellables); if let account = conversation?.account { conversation?.lastActivityPublisher.throttleFixed(for: 0.1, scheduler: ChatsListTableViewCell.throttlingQueue, latest: true).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] value in self?.set(lastActivity: value, account: account); }).store(in: &cancellables); } } } func update(conversation: Conversation) { lastMessageLabel.numberOfLines = Settings.recentsMessageLinesNo; lastMessageLabel.invalidateIntrinsicContentSize(); lastMessageLabel.setNeedsLayout(); self.conversation = conversation; } private func set(unread: Int) { self.badge.title = unread > 0 ? "\(unread)" : nil; } private func set(lastActivity: LastConversationActivity?, account: BareJID) { if let lastActivity = lastActivity { switch lastActivity { case .message(let lastMessage, let direction, let sender): if lastMessage.starts(with: "/me ") { let nick = sender ?? (direction == .incoming ? (nameLabel.text ?? "") : (AccountManager.getAccount(for: account)?.nickname ?? NSLocalizedString("Me", comment: "me label for conversation log"))); let baseFontDescriptor = UIFont.preferredFont(forTextStyle: .subheadline).fontDescriptor; let fontDescriptor = baseFontDescriptor.withSymbolicTraits([.traitBold, .traitItalic]); let font = UIFont(descriptor: fontDescriptor ?? baseFontDescriptor, size: 0); let msg = NSMutableAttributedString(string: "\(nick) ", attributes: [.font: font]); msg.append(NSAttributedString(string: "\(lastMessage.dropFirst(4))", attributes: [.font: font])); lastMessageLabel.attributedText = msg; } else { let font = UIFont.preferredFont(forTextStyle: .subheadline); let msg = NSMutableAttributedString(string: lastMessage); Markdown.applyStyling(attributedString: msg, defTextStyle: .subheadline, showEmoticons: Settings.showEmoticons); if sender != nil { let prefixFontDescription = font.fontDescriptor.withSymbolicTraits(.traitBold); let prefix = NSMutableAttributedString(string: "\(sender!): ", attributes: [.font: prefixFontDescription != nil ? UIFont(descriptor: prefixFontDescription!, size: 0) : font]); prefix.append(msg); lastMessageLabel.attributedText = prefix; } else { lastMessageLabel.attributedText = msg; } } case .invitation(_, _, let sender): let font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .subheadline).withSymbolicTraits([.traitItalic, .traitBold, .traitCondensed])!, size: 0); let msg = NSAttributedString(string: "📨 \(NSLocalizedString("Invitation", comment: "invitation label for chats list"))", attributes: [.font: font, .foregroundColor: lastMessageLabel.textColor!.withAlphaComponent(0.8)]); if let prefix = sender != nil ? NSMutableAttributedString(string: "\(sender!): ") : nil { prefix.append(msg); lastMessageLabel.attributedText = prefix; } else { lastMessageLabel.attributedText = msg; } case .attachment(_, _, let sender): let font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .subheadline).withSymbolicTraits([.traitItalic, .traitBold, .traitCondensed])!, size: 0); let msg = NSAttributedString(string: "📎 \(NSLocalizedString("Attachment", comment: "attachemt label for conversations list"))", attributes: [.font: font, .foregroundColor: lastMessageLabel.textColor!.withAlphaComponent(0.8)]); if let prefix = sender != nil ? NSMutableAttributedString(string: "\(sender!): ") : nil { prefix.append(msg); lastMessageLabel.attributedText = prefix; } else { lastMessageLabel.attributedText = msg; } case .location(_, _, let sender): let font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .subheadline).withSymbolicTraits([.traitItalic, .traitBold, .traitCondensed])!, size: 0); let msg = NSAttributedString(string: "📍 \(NSLocalizedString("Location", comment: "attachemt label for conversations list"))", attributes: [.font: font, .foregroundColor: lastMessageLabel.textColor!.withAlphaComponent(0.8)]); if let prefix = sender != nil ? NSMutableAttributedString(string: "\(sender!): ") : nil { prefix.append(msg); lastMessageLabel.attributedText = prefix; } else { lastMessageLabel.attributedText = msg; } } } else { lastMessageLabel.text = nil; } } } ================================================ FILE: SiskinIM/chats/ChatsListViewController.swift ================================================ // // ChatListViewController.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import UserNotifications import Martin import Combine class LabelWithInsets: UILabel { var insets: UIEdgeInsets = UIEdgeInsets(top: 1, left: 5, bottom: 1, right: 5) override var intrinsicContentSize: CGSize { let size = super.intrinsicContentSize; return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom); } } class BadgedButton: UIButton { let badgeLabel = LabelWithInsets(); var badge: String? { didSet { badgeLabel.text = badge; isBadgeVisible = badge != nil; badgeLabel.sizeToFit(); badgeLabel.layer.cornerRadius = badgeLabel.intrinsicContentSize.height / 2; } } var isBadgeVisible: Bool = false { didSet { guard oldValue != isBadgeVisible else { return; } badgeLabel.translatesAutoresizingMaskIntoConstraints = false; badgeLabel.backgroundColor = UIColor.systemRed; badgeLabel.textColor = UIColor.white; badgeLabel.font = UIFont.systemFont(ofSize: UIFont.smallSystemFontSize, weight: .medium) badgeLabel.textAlignment = .center; badgeLabel.layer.masksToBounds = true; if isBadgeVisible { self.addSubview(badgeLabel); NSLayoutConstraint.activate([ badgeLabel.rightAnchor.constraint(equalTo: self.rightAnchor, constant: 2), badgeLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: 2) ]) } else { badgeLabel.removeFromSuperview(); } } } } private var badgeHandle: UInt8 = 0 class BadgedBarButtonItem: UIBarButtonItem { private var badgeLayer: CAShapeLayer? { return objc_getAssociatedObject(self, &badgeHandle) as? CAShapeLayer; } public func setBadge(text: String?) { badgeLayer?.removeFromSuperlayer() guard let text = text, !text.isEmpty else { return } guard let view = self.value(forKey: "view") as? UIView else { return; } let font = UIFont.monospacedDigitSystemFont(ofSize: UIFont.smallSystemFontSize, weight: .regular); let badgeSize = text.size(withAttributes: [.font: font]) let width = max(badgeSize.width + 2, badgeSize.height) let badgeFrame = CGRect(origin: CGPoint(x: view.frame.width - width - 8, y: 4), size: CGSize(width: width, height: badgeSize.height)) let layer = CAShapeLayer() layer.path = UIBezierPath(roundedRect: badgeFrame, cornerRadius: 7).cgPath layer.fillColor = UIColor.red.cgColor; layer.strokeColor = UIColor.red.cgColor view.layer.addSublayer(layer) let label = CATextLayer() label.string = text label.alignmentMode = .center label.font = font label.fontSize = font.pointSize label.foregroundColor = UIColor.white.cgColor label.frame = badgeFrame label.cornerRadius = label.frame.height / 2 label.foregroundColor = UIColor.white.cgColor; label.backgroundColor = UIColor.clear.cgColor label.contentsScale = UIScreen.main.scale layer.addSublayer(label) objc_setAssociatedObject(self, &badgeHandle, layer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) layer.zPosition = 1000 } } class ChatsListViewController: UITableViewController { @IBOutlet var addMucButton: UIBarButtonItem! @IBOutlet var settingsButton: BadgedBarButtonItem!; var dataSource: ChatsDataSource?; private var cancellables: Set = []; override func viewDidLoad() { dataSource = ChatsDataSource(controller: self); super.viewDidLoad(); tableView.dataSource = self; setColors(); settingsButton.image = UIImage(systemName: "gear"); XmppService.instance.$clients.combineLatest(XmppService.instance.$connectedClients).map({ (clients, connectedClients) -> Int in return (clients.count - connectedClients.count) + AccountManager.getAccounts().filter({(name)->Bool in return AccountSettings.lastError(for: name) != nil }).count; }).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] value in self?.settingsButton.setBadge(text: value == 0 ? nil : "\(value)") }).store(in: &cancellables); } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); DBChatStore.instance.$unreadMessagesCount.throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true).map({ $0 == 0 ? nil : "\($0)" }).sink(receiveValue: { [weak self] value in self?.navigationController?.tabBarItem.badgeValue = value; }).store(in: &cancellables); Settings.$recentsMessageLinesNo.removeDuplicates().receive(on: DispatchQueue.main).sink(receiveValue: { _ in self.tableView.reloadData(); }).store(in: &cancellables); animate(); if #available(iOS 14.0, *) { addMucButton.action = nil; addMucButton.target = nil; addMucButton.primaryAction = nil let newPrivateGC = UIAction(title: NSLocalizedString("New private group chat", comment: "label for chats list new converation action"), image: nil, handler: { action in let navigation = UIStoryboard(name: "MIX", bundle: nil).instantiateViewController(withIdentifier: "ChannelCreateNavigationViewController") as! UINavigationController; (navigation.visibleViewController as? ChannelCreateViewController)?.kind = .adhoc; navigation.modalPresentationStyle = .formSheet; self.present(navigation, animated: true, completion: nil); }); let newPublicGC = UIAction(title: NSLocalizedString("New public group chat", comment: "label for chats list new converation action"), image: nil, handler: { action in let navigation = UIStoryboard(name: "MIX", bundle: nil).instantiateViewController(withIdentifier: "ChannelCreateNavigationViewController") as! UINavigationController; (navigation.visibleViewController as? ChannelCreateViewController)?.kind = .stable; navigation.modalPresentationStyle = .formSheet; self.present(navigation, animated: true, completion: nil); }); let joinGC = UIAction(title: NSLocalizedString("Join group chat", comment: "label for chats list new converation action"), image: nil, handler: { action in let navigation = UIStoryboard(name: "MIX", bundle: nil).instantiateViewController(withIdentifier: "ChannelJoinNavigationViewController") as! UINavigationController; navigation.modalPresentationStyle = .formSheet; self.present(navigation, animated: true, completion: nil); }) let deferedItems = UIDeferredMenuElement({ callback in if CallManager.instance != nil && !MeetEventHandler.instance.supportedAccounts.isEmpty { callback([ UIAction(title: NSLocalizedString("Create meeting", comment: "label for chats list new converation action"), image: UIImage(systemName: "person.crop.rectangle"), handler: { action in let selector = CreateMeetingViewController(style: .plain); let navController = UINavigationController(rootViewController: selector); self.present(navController, animated: true, completion: nil); }) ]); } else { callback([]); } }); addMucButton.menu = UIMenu(title: "", children: [newPrivateGC, newPublicGC, joinGC, deferedItems]); } } private func animate() { guard let coordinator = self.transitionCoordinator else { return; } coordinator.animate(alongsideTransition: { [weak self] context in self?.setColors(); }, completion: nil); } private func setColors() { let appearance = UINavigationBarAppearance(); appearance.configureWithDefaultBackground(); appearance.backgroundColor = UIColor(named: "chatslistSemiBackground"); appearance.backgroundEffect = UIBlurEffect(style: .systemUltraThinMaterialDark); navigationController?.navigationBar.standardAppearance = appearance; navigationController?.navigationBar.scrollEdgeAppearance = appearance; navigationController?.navigationBar.barTintColor = UIColor(named: "chatslistBackground"); navigationController?.navigationBar.tintColor = UIColor.white; } override func viewDidDisappear(_ animated: Bool) { cancellables.removeAll(); super.viewDidDisappear(animated); } deinit { NotificationCenter.default.removeObserver(self); } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } override func numberOfSections(in tableView: UITableView) -> Int { return 1; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return dataSource?.count ?? 0; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cellIdentifier = Settings.recentsMessageLinesNo == 1 ? "ChatsListTableViewCellNew" : "ChatsListTableViewCellBig"; let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath as IndexPath) as! ChatsListTableViewCell; if let item = dataSource?.item(at: indexPath) { cell.update(conversation: item.chat); } cell.avatarStatusView.updateCornerRadius(); return cell; } override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { if let accountCell = cell as? ChatsListTableViewCell { accountCell.avatarStatusView.updateCornerRadius(); } } override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { if (indexPath.section == 0) { return true; } return false; } override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { guard let item = dataSource!.item(at: indexPath)?.chat else { return nil; } var actions: [UIContextualAction] = []; switch item { case let room as Room: actions.append(UIContextualAction(style: .normal, title: NSLocalizedString("Leave", comment: "button label"), handler: { (action, view, completion) in room.context?.module(.pepBookmarks).setConferenceAutojoin(false, for: JID(room.jid)) room.context?.module(.muc).leave(room: room); room.checkTigasePushNotificationRegistrationStatus { (result) in switch result { case .failure(_): break; case .success(let value): guard value else { return; } room.registerForTigasePushNotification(false, completionHandler: { (regResult) in DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Push notifications", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("You've left there room %@ and push notifications for this room were disabled!\nYou may need to reenable them on other devices.", comment: "alert body"), room.name ?? room.roomJid.stringValue), preferredStyle: .actionSheet); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); alert.popoverPresentationController?.sourceView = self.view; alert.popoverPresentationController?.sourceRect = tableView.rectForRow(at: indexPath); self.present(alert, animated: true, completion: nil); } }) } } self.discardNotifications(for: room); completion(true); })) if room.affiliation == .owner { actions.append(UIContextualAction(style: .destructive, title: NSLocalizedString("Destroy", comment: "button label"), handler: { (action, view, completion) in DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Channel destuction", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("You are about to destroy channel %@. This will remove the channel on the server, remove remote history archive, and kick out all participants. Are you sure?", comment: "alert body"), room.roomJid.stringValue), preferredStyle: .actionSheet); alert.addAction(UIAlertAction(title: NSLocalizedString("Yes", comment: "button label"), style: .destructive, handler: { action in room.context?.module(.pepBookmarks).remove(bookmark: Bookmarks.Conference(name: item.jid.localPart!, jid: JID(room.jid), autojoin: false)); room.context?.module(.muc).destroy(room: room); self.discardNotifications(for: room); completion(true); })); alert.addAction(UIAlertAction(title: NSLocalizedString("No", comment: "button label"), style: .default, handler: { action in completion(false) })) alert.popoverPresentationController?.sourceView = self.view; alert.popoverPresentationController?.sourceRect = tableView.rectForRow(at: indexPath); self.present(alert, animated: true, completion: nil); } })) } case let chat as Chat: actions.append(UIContextualAction(style: .normal, title: NSLocalizedString("Close", comment: "button label"), handler: { (action, view, completion) in let result = DBChatStore.instance.close(chat: chat); if result { self.discardNotifications(for: chat); } completion(result); })) case let channel as Channel: actions.append(UIContextualAction(style: .normal, title: NSLocalizedString("Close", comment: "button label"), handler: { (action, view, completion) in if let mixModule = channel.context?.module(.mix), let userJid = channel.context?.userBareJid { let leaveFn: ()-> Void = { mixModule.leave(channel: channel, completionHandler: { result in switch result { case .success(_): self.discardNotifications(for: channel); completion(true); case .failure(_): completion(false); break; } }); } mixModule.retrieveConfig(for: channel.channelJid, completionHandler: { result in switch result { case .success(let data): if let adminsField: JidMultiField = data.getField(named: "Owner"), adminsField.value.contains(JID(userJid)) && adminsField.value.count == 1 { // you need to pass the permission or delete channel.. DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Leaving channel", comment: "leaving channel title"), message: NSLocalizedString("You are the last person with ownership of this channel. Please decide what to do with the channel.", comment: "leaving channel text"), preferredStyle: .actionSheet); alert.addAction(UIAlertAction(title: NSLocalizedString("Destroy", comment: "button label"), style: .destructive, handler: { _ in mixModule.destroy(channel: channel.channelJid, completionHandler: { result in switch result { case .success(_): break; case .failure(let error): DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Channel destruction failed!", comment: "alert window title"), message: String.localizedStringWithFormat(NSLocalizedString("It was not possible to destroy channel %@. Server returned an error: %@", comment: "alert window message"), channel.name ?? channel.channelJid.stringValue, error.message ?? error.description), preferredStyle: .alert) alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Button"), style: .default, handler: nil)); self.present(alert, animated: true, completion: nil); } } }) })); let otherParticipants = channel.participants.filter({ $0.jid != nil && $0.jid != userJid }); if !otherParticipants.isEmpty { alert.addAction(UIAlertAction(title: NSLocalizedString("Pass ownership", comment: "button label"), style: .default, handler: { _ in if let navController = UIStoryboard(name: "MIX", bundle: nil).instantiateViewController(withIdentifier: "ChannelSelectNewOwnerViewNavController") as? UINavigationController, let controller = navController.visibleViewController as? ChannelSelectNewOwnerViewController { controller.channel = channel; controller.participants = otherParticipants.sorted(by: { p1, p2 in return p1.nickname ?? p1.jid?.stringValue ?? p1.id < p2.nickname ?? p2.jid?.stringValue ?? p2.id; }); controller.completionHandler = { result in guard let participant = result, let jid = participant.jid else { completion(false); return; } adminsField.value = adminsField.value.filter({ $0.bareJid != userJid }) + [JID(jid)]; mixModule.updateConfig(for: channel.channelJid, config: data, completionHandler: { _ in leaveFn(); }) } self.present(navController, animated: true, completion: nil); } })); } alert.addAction(UIAlertAction(title: NSLocalizedString("Leave", comment: "button label"), style: .default, handler: { _ in leaveFn(); })) alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: { _ in completion(false); })) alert.popoverPresentationController?.sourceView = self.view; alert.popoverPresentationController?.sourceRect = tableView.rectForRow(at: indexPath); self.present(alert, animated: true, completion: nil); } } else { leaveFn(); } case .failure(let error): leaveFn(); } }); } else { completion(false); } })) if channel.permissions?.contains(.changeConfig) ?? false { actions.append(UIContextualAction(style: .destructive, title: NSLocalizedString("Destroy", comment: "button label"), handler: { (action, view, completion) in DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Channel destuction", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("You are about to destroy channel %@. This will remove the channel on the server, remove remote history archive, and kick out all participants. Are you sure?", comment: "alert body"), channel.channelJid.stringValue), preferredStyle: .actionSheet); alert.addAction(UIAlertAction(title: NSLocalizedString("Yes", comment: "button label"), style: .destructive, handler: { action in channel.context?.module(.mix).destroy(channel: channel.channelJid, completionHandler: { result in switch result { case .success(_): self.discardNotifications(for: channel); completion(true); case .failure(_): completion(false); break; } }) })); alert.addAction(UIAlertAction(title: NSLocalizedString("No", comment: "button label"), style: .default, handler: { action in completion(false) })) alert.popoverPresentationController?.sourceView = self.view; alert.popoverPresentationController?.sourceRect = tableView.rectForRow(at: indexPath); self.present(alert, animated: true, completion: nil); } })) } default: return nil; } let config = UISwipeActionsConfiguration(actions: actions); config.performsFirstActionWithFullSwipe = actions.count == 1; return config; } override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let item = dataSource!.item(at: indexPath)?.chat else { return nil; } switch item { case let chat as Chat: return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: contextMenuActionProvider(for: chat)); default: return nil; } } private func contextMenuActionProvider(for chat: Chat) -> UIContextMenuActionProvider? { return { suggestedActions -> UIMenu? in var actions: [UIMenuElement] = []; if let context = chat.context, let blockingModule = chat.context?.module(.blockingCommand), blockingModule.isAvailable { if blockingModule.blockedJids?.contains(JID(chat.jid)) ?? false { actions.append(UIAction(title: NSLocalizedString("Unblock", comment: "context menu action"), image: UIImage(systemName: "hand.raised"), handler: { _ in blockingModule.unblock(jids: [JID(chat.jid)], completionHandler: { _ in }) })) } else if blockingModule.blockedJids?.contains(JID(chat.jid.domain)) ?? false { actions.append(UIAction(title: NSLocalizedString("Unblock server", comment: "context menu action"), image: UIImage(systemName: "hand.raised"), handler: { _ in let alert = UIAlertController(title: NSLocalizedString("Server is blocked", comment: "alert title - unblock communication with server"), message: String.localizedStringWithFormat(NSLocalizedString("All communication with users from %@ is blocked. Do you wish to unblock communication with this server?", comment: "alert message - unblock communication with server"), chat.jid.domain), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("Unblock", comment: "unblock server"), style: .default, handler: { _ in blockingModule.unblock(jids: [JID(chat.jid.domain), JID(chat.jid)], completionHandler: { _ in }) })) alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "cancel operation"), style: .cancel, handler: { _ in })) self.present(alert, animated: true); })) } else { var items = [UIMenuElement](); if blockingModule.isReportingSupported { items.append(UIAction(title: NSLocalizedString("Report spam", comment: "context menu action"), attributes: .destructive, handler: { _ in blockingModule.block(jid: JID(chat.jid), report: .init(cause: .spam), completionHandler: { _ in }); })); items.append(UIAction(title: NSLocalizedString("Report abuse", comment: "context menu action"), attributes: .destructive, handler: { _ in blockingModule.block(jid: JID(chat.jid), report: .init(cause: .abuse), completionHandler: { _ in }); })); } else { items.append(UIAction(title: NSLocalizedString("Block contact", comment: "context menu item"), attributes: .destructive, handler: { _ in blockingModule.block(jid: JID(chat.jid), completionHandler: { result in switch result { case .success(_): _ = DBChatStore.instance.close(chat: chat); case .failure(_): break; } }) })) } items.append(UIAction(title: NSLocalizedString("Block server", comment: "context menu item"), attributes: .destructive, handler: { _ in blockingModule.block(jid: JID(chat.jid.domain), completionHandler: { result in switch result { case .success(_): let blockedChats = DBChatStore.instance.chats(for: context).filter({ $0.jid.domain == chat.jid.domain }); for blockedChat in blockedChats { _ = DBChatStore.instance.close(chat: blockedChat); } case .failure(_): break; } }) })) items.append(UIAction(title: NSLocalizedString("Cancel", comment: "context menu action"), handler: { _ in })); actions.append(UIMenu(title: NSLocalizedString("Report & block…", comment: "context action label"), image: UIImage(systemName: "hand.raised"), children: items)); } } guard !actions.isEmpty else { return nil; } return UIMenu(title: "", children: actions); } } func discardNotifications(for item: Conversation) { let accountStr = item.account.stringValue.lowercased(); let jidStr = item.jid.stringValue.lowercased(); UNUserNotificationCenter.current().getDeliveredNotifications { (notifications) in var toRemove = [String](); for notification in notifications { if (notification.request.content.userInfo["account"] as? String)?.lowercased() == accountStr && (notification.request.content.userInfo["sender"] as? String)?.lowercased() == jidStr { toRemove.append(notification.request.identifier); } } UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: toRemove); } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath as IndexPath, animated: true); guard let item = dataSource!.item(at: indexPath)?.chat else { return; } var identifier: String!; var controller: UIViewController? = nil; switch item { case is Room: identifier = "RoomViewNavigationController"; controller = UIStoryboard(name: "Groupchat", bundle: nil).instantiateViewController(withIdentifier: identifier); case is Channel: identifier = "ChannelViewNavigationController"; controller = UIStoryboard(name: "MIX", bundle: nil).instantiateViewController(withIdentifier: identifier); default: identifier = "ChatViewNavigationController"; controller = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: identifier); } let navigationController = controller as? UINavigationController; let destination = navigationController?.visibleViewController ?? controller; if let baseChatViewController = destination as? BaseChatViewController { baseChatViewController.conversation = item; } destination?.hidesBottomBarWhenPushed = true; if controller != nil { self.showDetailViewController(controller!, sender: self); } } @IBAction func addMucButtonClicked(_ sender: UIBarButtonItem) { let controller = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet); controller.popoverPresentationController?.barButtonItem = sender; controller.addAction(UIAlertAction(title: NSLocalizedString("New private group chat", comment: "label for chats list new converation action"), style: .default, handler: { action in let navigation = UIStoryboard(name: "MIX", bundle: nil).instantiateViewController(withIdentifier: "ChannelCreateNavigationViewController") as! UINavigationController; (navigation.visibleViewController as? ChannelCreateViewController)?.kind = .adhoc; navigation.modalPresentationStyle = .formSheet; self.present(navigation, animated: true, completion: nil); })); controller.addAction(UIAlertAction(title: NSLocalizedString("New public group chat", comment: "label for chats list new converation action"), style: .default, handler: { action in let navigation = UIStoryboard(name: "MIX", bundle: nil).instantiateViewController(withIdentifier: "ChannelCreateNavigationViewController") as! UINavigationController; (navigation.visibleViewController as? ChannelCreateViewController)?.kind = .stable; navigation.modalPresentationStyle = .formSheet; self.present(navigation, animated: true, completion: nil); })); controller.addAction(UIAlertAction(title: NSLocalizedString("Join group chat", comment: "label for chats list new converation action"), style: .default, handler: { action in let navigation = UIStoryboard(name: "MIX", bundle: nil).instantiateViewController(withIdentifier: "ChannelJoinNavigationViewController") as! UINavigationController; navigation.modalPresentationStyle = .formSheet; self.present(navigation, animated: true, completion: nil); })); if CallManager.instance != nil && !MeetEventHandler.instance.supportedAccounts.isEmpty { controller.addAction(UIAlertAction(title: NSLocalizedString("Create meeting", comment: "label for chats list new converation action"), style: .default, handler: { action in let selector = CreateMeetingViewController(style: .plain); let navController = UINavigationController(rootViewController: selector); self.present(navController, animated: true, completion: nil); })) } controller.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); self.present(controller, animated: true, completion: nil); } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection); // if #available(iOS 13.0, *) { // let changed = previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) ?? false; // // let subtype: Appearance.SubColorType = traitCollection.userInterfaceStyle == .dark ? .dark : .light; // let colorType = Appearance.current.colorType; // Appearance.current = Appearance.values.first(where: { (item) -> Bool in // return item.colorType == colorType && item.subtype == subtype; // }) // } } fileprivate func closeBaseChatView(for account: BareJID, jid: BareJID) { DispatchQueue.main.async { if let navController = self.splitViewController?.viewControllers.first(where: { c -> Bool in return c is UINavigationController; }) as? UINavigationController, let controller = navController.visibleViewController as? BaseChatViewController { if controller.conversation.account == account && controller.conversation.jid == jid { self.showDetailViewController(self.storyboard!.instantiateViewController(withIdentifier: "emptyDetailViewController"), sender: self); } } } } struct ConversationItem: Hashable { static func == (lhs: ConversationItem, rhs: ConversationItem) -> Bool { return lhs.chat.id == rhs.chat.id; } var name: String { return chat.displayName; } let chat: Conversation; let timestamp: Date; func hash(into hasher: inout Hasher) { hasher.combine(chat.id); } } class ChatsDataSource { weak var controller: ChatsListViewController?; fileprivate var dispatcher = DispatchQueue(label: "chats_data_source", qos: .background); var count: Int { self.items.count; } private var items: [ConversationItem] = []; private var cancellables: Set = []; init(controller: ChatsListViewController) { self.controller = controller; DBChatStore.instance.$conversations.throttleFixed(for: 0.1, scheduler: self.dispatcher, latest: true).sink(receiveValue: { [weak self] items in self?.update(items: items); }).store(in: &cancellables); } func update(items: [Conversation]) { let newItems = items.map({ conversation in ConversationItem(chat: conversation, timestamp: conversation.timestamp) }).sorted(by: { (c1,c2) in c1.timestamp > c2.timestamp }); let oldItems = self.items; let diffs = newItems.difference(from: oldItems).inferringMoves(); var removed: [Int] = []; var inserted: [Int] = []; var moved: [(Int,Int)] = []; for action in diffs { switch action { case .remove(let offset, _, let to): if let idx = to { moved.append((offset, idx)); } else { removed.append(offset); } case .insert(let offset, _, let from): if from == nil { inserted.append(offset); } } } guard (!removed.isEmpty) || (!moved.isEmpty) || (!inserted.isEmpty) else { return; } let updateFn = { self.items = newItems; self.controller?.tableView.beginUpdates(); if !removed.isEmpty { self.controller?.tableView.deleteRows(at: removed.map({ IndexPath(row: $0, section: 0) }), with: .fade); } for (from,to) in moved { self.controller?.tableView.moveRow(at: IndexPath(row: from, section: 0), to: IndexPath(row: to, section: 0)); } if !inserted.isEmpty { self.controller?.tableView.insertRows(at: inserted.map({ IndexPath(row: $0, section: 0) }), with: .fade); } self.controller?.tableView.endUpdates(); } if #available(iOS 13.2, *) { DispatchQueue.main.sync { updateFn(); } } else { updateFn(); } } func item(at indexPath: IndexPath) -> ConversationItem? { return self.items[indexPath.row]; } func item(at index: Int) -> ConversationItem? { return self.items[index]; } } } ================================================ FILE: SiskinIM/contacts/ContactBasicTableViewCell.swift ================================================ // // ContactBasicTableViewCell.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class ContactBasicTableViewCell: UITableViewCell { @IBOutlet var avatarView: UIImageView! @IBOutlet var nameView: UILabel! @IBOutlet var companyView: UILabel! @IBOutlet var jidView: UILabel!; @IBOutlet var accountView: UILabel!; var account: BareJID!; var jid: BareJID!; var vcard: VCard? { didSet { var fn = vcard?.fn; if fn == nil { if let given = vcard?.givenName, let surname = vcard?.surname { fn = "\(given) \(surname)"; } } nameView.text = fn ?? jid.stringValue; let org = vcard?.organizations.first?.name; let role = vcard?.role; if org != nil && role != nil { companyView.text = "\(role!) at \(org!)"; } else { companyView.text = org ?? role; } avatarView.image = AvatarManager.instance.avatar(for: jid, on: account) ?? AvatarManager.instance.defaultAvatar; jidView.text = jid.stringValue; accountView.text = String.localizedStringWithFormat(NSLocalizedString("using %@", comment: "account info label"), account.stringValue); } } override func awakeFromNib() { super.awakeFromNib() // Initialization code avatarView.layer.masksToBounds = true; avatarView.layer.cornerRadius = avatarView.frame.width / 2; } override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) // Configure the view for the selected state } } ================================================ FILE: SiskinIM/contacts/ContactFormTableViewCell.swift ================================================ // // ContactFormTableViewCell.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class ContactFormTableViewCell: UITableViewCell { @IBOutlet var typeView: UILabel! @IBOutlet var labelView: UILabel! override func awakeFromNib() { super.awakeFromNib() // Initialization code } override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) // Configure the view for the selected state } } ================================================ FILE: SiskinIM/contacts/ContactViewController.swift ================================================ // // ContactViewController.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import MartinOMEMO class ContactViewController: UITableViewController { var account: BareJID!; var jid: BareJID!; var vcard: VCard? { didSet { self.reloadData(); } } var chat: Chat?; var omemoIdentities: [Identity] = []; var addresses: [VCard.Address] { return vcard?.addresses ?? []; } var phones: [VCard.Telephone] { return vcard?.telephones ?? []; } var emails: [VCard.Email] { return vcard?.emails ?? []; } fileprivate var sections: [Sections] = [.basic]; override func viewDidLoad() { super.viewDidLoad() DBVCardStore.instance.vcard(for: jid, completionHandler: { vcard in if vcard == nil { self.refreshVCard(); } else { DispatchQueue.main.async { self.vcard = vcard; } } }) omemoIdentities = DBOMEMOStore.instance.identities(forAccount: account, andName: jid.stringValue); tableView.contentInset = UIEdgeInsets(top: -1, left: 0, bottom: 0, right: 0); reloadData(); } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } @IBAction func doneClicked(_ sender: UIBarButtonItem) { self.dismiss(animated: true, completion: nil); } @IBAction func refreshVCard(_ sender: UIBarButtonItem) { refreshVCard(); } func refreshVCard() { DispatchQueue.global(qos: .background).async() { VCardManager.instance.refreshVCard(for: self.jid, on: self.account, completionHandler: { result in switch result { case .success(let vcard): DispatchQueue.main.async { self.vcard = vcard; } case .failure(_): break; } }) } } func reloadData() { var sections: [Sections] = [.basic]; if chat != nil { sections.append(.settings); sections.append(.attachments); sections.append(.encryption); } if phones.count > 0 { sections.append(.phones); } if emails.count > 0 { sections.append(.emails); } if addresses.count > 0 { sections.append(.addresses); } self.sections = sections; tableView.reloadData(); } // MARK: - Table view data source override func numberOfSections(in: UITableView) -> Int { return sections.count; } override func tableView(_ tableView: UITableView, numberOfRowsInSection sectionNo: Int) -> Int { switch sections[sectionNo] { case .basic: return 1; case .settings: return 2; case .attachments: return 1; case .encryption: return omemoIdentities.count + 1; case .phones: return phones.count; case .emails: return emails.count; case .addresses: return addresses.count; } } override func tableView(_ tableView: UITableView, titleForHeaderInSection sectionNo: Int) -> String? { return sections[sectionNo].label; } override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { if section == 0 { return 1.0; } return super.tableView(tableView, heightForHeaderInSection: section); } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { return ""; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch sections[indexPath.section] { case .basic: let cell = tableView.dequeueReusableCell(withIdentifier: "BasicInfoCell", for: indexPath) as! ContactBasicTableViewCell; cell.account = account; cell.jid = jid; cell.vcard = vcard; return cell; case .settings: switch SettingsOptions(rawValue: indexPath.row)! { case .mute: let cell = tableView.dequeueReusableCell(withIdentifier: "MuteContactCell", for: indexPath); let btn = UISwitch(frame: .zero); btn.isOn = (chat?.options.notifications ?? .always == .none); btn.isEnabled = chat != nil; btn.addTarget(self, action: #selector(muteContactChanged), for: .valueChanged); cell.accessoryView = btn; return cell; case .block: let cell = tableView.dequeueReusableCell(withIdentifier: "BlockContactCell", for: indexPath); let btn = UISwitch(frame: .zero); if let blockingModule = XmppService.instance.getClient(for: account)?.module(.blockingCommand), blockingModule.isAvailable { btn.isOn = (blockingModule.blockedJids?.contains(JID(jid)) ?? false) || (blockingModule.blockedJids?.contains(JID(jid.domain)) ?? false); btn.isEnabled = true; } else { btn.isOn = false; btn.isEnabled = false; } btn.addTarget(self, action: #selector(blockContactChanged), for: .valueChanged); cell.accessoryView = btn; return cell; } case .attachments: let cell = tableView.dequeueReusableCell(withIdentifier: "AttachmentsCell", for: indexPath); return cell; case .encryption: if indexPath.row == 0 { let cell = tableView.dequeueReusableCell(withIdentifier: "OMEMOEncryptionCell", for: indexPath) as! EnumTableViewCell; if let chat = self.chat { cell.bind({ cell in cell.assign(from: chat.optionsPublisher.map({ $0.encryption?.description ?? NSLocalizedString("Default", comment: "encryption default label") }).receive(on: DispatchQueue.main).eraseToAnyPublisher()); }) } else { cell.detailTextLabel?.text = NSLocalizedString("Default", comment: "encryption default label"); } return cell; } else { let cell = tableView.dequeueReusableCell(withIdentifier: "OMEMOIdentityCell", for: indexPath) as! OMEMOIdentityTableViewCell; let identity = omemoIdentities[indexPath.row - 1]; var fingerprint = String(identity.fingerprint.dropFirst(2)); var idx = fingerprint.startIndex; for _ in 0..<(fingerprint.count / 8) { idx = fingerprint.index(idx, offsetBy: 8); fingerprint.insert(" ", at: idx); idx = fingerprint.index(after: idx); } cell.deviceLabel?.text = String.localizedStringWithFormat(NSLocalizedString("Device: %@", comment: "label for omemo device id"), "\(identity.address.deviceId)"); cell.identityLabel.text = fingerprint; cell.trustSwitch.isEnabled = identity.status.isActive; cell.trustSwitch.isOn = identity.status.trust == .trusted || identity.status.trust == .undecided; let account = self.account!; cell.valueChangedListener = { (sender) in _ = DBOMEMOStore.instance.setStatus(identity.status.toTrust(sender.isOn ? .trusted : .compromised), forIdentity: identity.address, andAccount: account); } return cell; } case .phones: let cell = tableView.dequeueReusableCell(withIdentifier: "ContactFormCell", for: indexPath) as! ContactFormTableViewCell; let phone = phones[indexPath.row]; let type = getVCardEntryTypeLabel(for: phone.types.first ?? VCard.EntryType.home); cell.typeView.text = type; cell.labelView.text = phone.number?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines); return cell; case .emails: let cell = tableView.dequeueReusableCell(withIdentifier: "ContactFormCell", for: indexPath) as! ContactFormTableViewCell; let email = emails[indexPath.row]; let type = getVCardEntryTypeLabel(for: email.types.first ?? VCard.EntryType.home); cell.typeView.text = type; cell.labelView.text = email.address?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines); return cell; case .addresses: let cell = tableView.dequeueReusableCell(withIdentifier: "AddressCell", for: indexPath ) as! ContactFormTableViewCell; let address = addresses[indexPath.row]; let type = getVCardEntryTypeLabel(for: address.types.first ?? VCard.EntryType.home); cell.typeView.text = type; var text = ""; var start = true; if let street = address.street { text += street; start = false; } if let code = address.postalCode { if !start { text += "\n"; } text += code + " "; start = false; } if let locality = address.locality { if !start && address.postalCode == nil { text += "\n"; } text += locality; start = false; } if let country = address.country { if !start { text += "\n"; } text += country; start = false; } cell.labelView.text = text; return cell; // default: // let cell = tableView.dequeueReusableCell(withIdentifier: "ContactFormCell", for: indexPath as IndexPath) as! ContactFormTableViewCell; // return cell; } } override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { if indexPath.section == 0 { return nil; } return indexPath; } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath as IndexPath, animated: true) switch sections[indexPath.section] { case .basic: return; case .settings: return; case .attachments: return; case .encryption: if indexPath.row == 0 { // handle change of encryption method! let controller = TablePickerViewController(style: .grouped, options: [nil, ChatEncryption.none, ChatEncryption.omemo], value: chat?.options.encryption, labelFn: { value in guard let v = value else { return NSLocalizedString("Default", comment: "encryption default label"); } return v.description; }); controller.sink(receiveValue: { [weak self] value in self?.chat?.updateOptions({ options in options.encryption = value; }) }); self.navigationController?.pushViewController(controller, animated: true); } case .phones: if let url = URL(string: "tel:" + phones[indexPath.row].number!) { UIApplication.shared.open(url); } case .emails: if let url = URL(string: "mailto:" + emails[indexPath.row].address!) { UIApplication.shared.open(url); } case .addresses: let address = addresses[indexPath.row]; var parts = [String](); if let street = address.street { parts.append(street); } if let locality = address.locality { parts.append(locality); } if let country = address.country { parts.append(country); } let query = parts.joined(separator: ",").addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!; if let url = URL(string: "http://maps.apple.com/?q=" + query) { UIApplication.shared.open(url); } } } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == "chatShowAttachments" { if let attachmentsController = segue.destination as? ChatAttachmentsController { attachmentsController.conversation = self.chat; } } } func getVCardEntryTypeLabel(for type: VCard.EntryType) -> String? { switch type { case .home: return NSLocalizedString("Home", comment: "address type"); case .work: return NSLocalizedString("Work", comment: "address type"); } } @objc func blockContactChanged(_ sender: UISwitch) { guard let blockingModule = XmppService.instance.getClient(for: account)?.module(.blockingCommand) else { sender.isOn = !sender.isOn; return; } let jid = JID(self.jid!); let account = self.account!; if sender.isOn { if DBRosterStore.instance.item(for: account, jid: jid) == nil { InvitationManager.instance.rejectPresenceSubscription(for: account, from: jid); } blockingModule.block(jids: [jid], completionHandler: { [weak sender] result in switch result { case .failure(_): sender?.isOn = false; case .success(_): break; } }) } else { if blockingModule.blockedJids?.contains(JID(jid.domain)) ?? false { let alert = UIAlertController(title: NSLocalizedString("Server is blocked", comment: "alert title - unblock communication with server"), message: String.localizedStringWithFormat(NSLocalizedString("All communication with users from %@ is blocked. Do you wish to unblock communication with this server?", comment: "alert message - unblock communication with server"), jid.domain), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("Unblock", comment: "unblock server"), style: .default, handler: { _ in blockingModule.unblock(jids: [JID(jid.domain), jid], completionHandler: { [weak sender] result in switch result { case .failure(_): sender?.isOn = true; default: break; } }) })) alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "cancel operation"), style: .cancel, handler: { [weak sender] _ in sender?.isOn = true; })) self.present(alert, animated: true); } else { blockingModule.unblock(jids: [jid], completionHandler: { [weak sender] result in switch result { case .failure(_): sender?.isOn = true; default: break; } }) } } } @objc func muteContactChanged(_ sender: UISwitch) { guard let account = self.account else { sender.isOn = !sender.isOn; return; } let newValue = sender.isOn; chat?.updateOptions({ (options) in options.notifications = newValue ? .none : .always; }, completionHandler: { if let pushModule = XmppService.instance.getClient(for: account)?.module(.push) as? SiskinPushNotificationsModule, let pushSettings = pushModule.pushSettings { pushModule.reenable(pushSettings: pushSettings, completionHandler: { result in switch result { case .success(_): break; case .failure(_): AccountSettings.pushHash(for: account, value: 0); } }); } }); } /* // MARK: - Navigation // In a storyboard-based application, you will often want to do a little preparation before navigation override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { // Get the new view controller using segue.destinationViewController. // Pass the selected object to the new view controller. } */ internal class ContactMessageEncryptionItem: TablePickerViewItemsProtocol { public static func description(of value: ChatEncryption?) -> String { guard value != nil else { return NSLocalizedString("Default", comment: "encryption default label"); } switch value! { case .omemo: return NSLocalizedString("OMEMO", comment: "encryption type"); case .none: return NSLocalizedString("None", comment: "encryption type"); } } let description: String; let value: ChatEncryption?; init(value: ChatEncryption?) { self.value = value; self.description = ContactMessageEncryptionItem.description(of: value); } } enum Sections { case basic case settings case attachments case encryption case phones case emails case addresses var label: String { switch self { case .basic: return ""; case .settings: return NSLocalizedString("Settings", comment: "contact details section"); case .attachments: return ""; case .encryption: return NSLocalizedString("Encryption", comment: "contact details section"); case .phones: return NSLocalizedString("Phones", comment: "contact details section"); case .emails: return NSLocalizedString("Emails", comment: "contact details section"); case .addresses: return NSLocalizedString("Addresses", comment: "contact details section"); } } } enum SettingsOptions: Int { case mute = 0 case block = 1 } } ================================================ FILE: SiskinIM/contacts/OMEMOIdentityTableViewCell.swift ================================================ // // OMEMOIdentityTableViewCell.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class OMEMOIdentityTableViewCell: UITableViewCell { @IBOutlet var deviceLabel: UILabel?; @IBOutlet var identityLabel: UILabel! @IBOutlet var trustSwitch: UISwitch! var valueChangedListener: ((UISwitch) -> Void)?; @IBAction func valueChanged(_ sender: UISwitch) { valueChangedListener?(sender); } } ================================================ FILE: SiskinIM/conversation/AttachmentChatTableViewCell.swift ================================================ // // AttachmentChatTableViewCell.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import MobileCoreServices import LinkPresentation import Martin import AVFoundation class AttachmentChatTableViewCell: BaseChatTableViewCell, UIContextMenuInteractionDelegate { @IBOutlet var customView: UIView!; override var backgroundColor: UIColor? { didSet { customView?.backgroundColor = backgroundColor; } } fileprivate var tapGestureRecognizer: UITapGestureRecognizer?; private var item: ConversationEntry?; private var linkView: UIView? { didSet { if let old = oldValue, let new = linkView { guard old != new else { return; } } if let view = oldValue { view.removeFromSuperview(); } if let view = linkView { self.customView.addSubview(view); if #available(iOS 13.0, *) { view.addInteraction(UIContextMenuInteraction(delegate: self)); } } } } override func awakeFromNib() { super.awakeFromNib(); tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapGestureDidFire)); tapGestureRecognizer?.cancelsTouchesInView = false; tapGestureRecognizer?.numberOfTapsRequired = 2; customView.addGestureRecognizer(tapGestureRecognizer!); customView.addInteraction(UIContextMenuInteraction(delegate: self)); } func set(item: ConversationEntry, url: String, appendix: ChatAttachmentAppendix) { self.item = item; super.set(item: item); self.customView?.isOpaque = true; // self.customView?.backgroundColor = self.backgroundColor; guard case let .attachment(url, appendix) = item.payload else { return; } if !(appendix.mimetype?.starts(with: "audio/") ?? false), let localUrl = DownloadStore.instance.url(for: "\(item.id)") { documentController = UIDocumentInteractionController(url: localUrl); var metadata = MetadataCache.instance.metadata(for: "\(item.id)"); let isNew = metadata == nil; if metadata == nil { metadata = LPLinkMetadata(); metadata!.originalURL = localUrl; } else { metadata!.originalURL = nil; //metadata!.url = nil; //metadata!.title = ""; //metadata!.originalURL = localUrl; metadata!.url = localUrl; } let linkView = /*(self.linkView as? LPLinkView) ??*/ LPLinkView(metadata: metadata!); linkView.setContentHuggingPriority(.defaultHigh, for: .vertical); linkView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical); linkView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal); linkView.translatesAutoresizingMaskIntoConstraints = false; linkView.isUserInteractionEnabled = false; self.linkView = linkView; NSLayoutConstraint.activate([ linkView.topAnchor.constraint(equalTo: self.customView.topAnchor, constant: 0), linkView.bottomAnchor.constraint(equalTo: self.customView.bottomAnchor, constant: 0), linkView.leadingAnchor.constraint(equalTo: self.customView.leadingAnchor, constant: 0), linkView.trailingAnchor.constraint(equalTo: self.customView.trailingAnchor, constant: 0), linkView.heightAnchor.constraint(lessThanOrEqualToConstant: 350) ]); if isNew { MetadataCache.instance.generateMetadata(for: localUrl, withId: "\(item.id)", completionHandler: { [weak self] meta1 in DispatchQueue.main.async { guard let that = self, meta1 != nil, that.item?.id == item.id else { return; } NotificationCenter.default.post(name: ConversationLogController.REFRESH_CELL, object: that); } }) } } else { documentController = nil; let attachmentInfo = (self.linkView as? AttachmentInfoView) ?? AttachmentInfoView(frame: .zero); //attachmentInfo.backgroundColor = self.backgroundColor; //attachmentInfo.isOpaque = true; //attachmentInfo.cellView = self; self.linkView = attachmentInfo; NSLayoutConstraint.activate([ customView.leadingAnchor.constraint(equalTo: attachmentInfo.leadingAnchor), customView.trailingAnchor.constraint(greaterThanOrEqualTo: attachmentInfo.trailingAnchor), customView.topAnchor.constraint(equalTo: attachmentInfo.topAnchor), customView.bottomAnchor.constraint(equalTo: attachmentInfo.bottomAnchor) ]) attachmentInfo.set(item: item, url: url, appendix: appendix); switch appendix.state { case .new: if DownloadStore.instance.url(for: "\(item.id)") == nil { let sizeLimit = Settings.fileDownloadSizeLimit; if sizeLimit > 0 { if (DBRosterStore.instance.item(for: item.conversation.account, jid: JID(item.conversation.jid))?.subscription ?? .none).isFrom || (DBChatStore.instance.conversation(for: item.conversation.account, with: item.conversation.jid) as? Room != nil) { _ = DownloadManager.instance.download(item: item, url: url, maxSize: sizeLimit >= Int.max ? Int64.max : Int64(sizeLimit * 1024 * 1024)); attachmentInfo.progress(show: true); return; } } attachmentInfo.progress(show: DownloadManager.instance.downloadInProgress(for: item)); } default: attachmentInfo.progress(show: DownloadManager.instance.downloadInProgress(for: item)); } } } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { suggestedActions -> UIMenu? in return self.prepareContextMenu(); }; } func prepareContextMenu() -> UIMenu { guard let item = self.item, case .attachment(let url, _) = item.payload else { return UIMenu(title: ""); } if let localUrl = DownloadStore.instance.url(for: "\(item.id)") { let items = [ UIAction(title: NSLocalizedString("Preview", comment: "attachment cell context action"), image: UIImage(systemName: "eye.fill"), handler: { action in self.open(url: localUrl, preview: true); }), UIAction(title: NSLocalizedString("Copy", comment: "attachment cell context action"), image: UIImage(systemName: "doc.on.doc"), handler: { action in UIPasteboard.general.strings = [url]; UIPasteboard.general.string = url; }), UIAction(title: NSLocalizedString("Share…", comment: "attachment cell context action"), image: UIImage(systemName: "square.and.arrow.up"), handler: { action in self.open(url: localUrl, preview: false); }), UIAction(title: NSLocalizedString("Delete", comment: "attachment cell context action"), image: UIImage(systemName: "trash"), attributes: [.destructive], handler: { action in DownloadStore.instance.deleteFile(for: "\(item.id)"); DBChatHistoryStore.instance.updateItem(for: item.conversation, id: item.id, updateAppendix: { appendix in appendix.state = .removed; }) }), UIAction(title: NSLocalizedString("More…", comment: "attachment cell context action"), image: UIImage(systemName: "ellipsis"), handler: { action in NotificationCenter.default.post(name: Notification.Name("tableViewCellShowEditToolbar"), object: self); }) ]; return UIMenu(title: "", image: nil, identifier: nil, options: [], children: items); } else { let items = [ UIAction(title: NSLocalizedString("Copy", comment: "attachment cell context action"), image: UIImage(systemName: "doc.on.doc"), handler: { action in UIPasteboard.general.strings = [url]; UIPasteboard.general.string = url; }), UIAction(title: NSLocalizedString("Download", comment: "attachment cell context action"), image: UIImage(systemName: "square.and.arrow.down"), handler: { action in self.download(for: item); }), UIAction(title: NSLocalizedString("More…", comment: "attachment cell context action"), image: UIImage(systemName: "ellipsis"), handler: { action in NotificationCenter.default.post(name: Notification.Name("tableViewCellShowEditToolbar"), object: self); }) ]; return UIMenu(title: "", image: nil, identifier: nil, options: [], children: items); } } override func prepareForReuse() { super.prepareForReuse(); (self.linkView as? AttachmentInfoView)?.prepareForReuse(); } @objc func tapGestureDidFire(_ recognizer: UITapGestureRecognizer) { downloadOrOpen(); } var documentController: UIDocumentInteractionController? { didSet { if let value = oldValue { for recognizer in value.gestureRecognizers { self.removeGestureRecognizer(recognizer) } } if let value = documentController { value.delegate = self; for recognizer in value.gestureRecognizers { self.addGestureRecognizer(recognizer) } } } } func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController { let rootViewController = ((UIApplication.shared.delegate as? AppDelegate)?.window?.rootViewController)!; if let viewController = rootViewController.presentingViewController { return viewController; } return rootViewController; } func open(url: URL, preview: Bool) { let documentController = UIDocumentInteractionController(url: url); documentController.delegate = self; if preview && documentController.presentPreview(animated: true) { self.documentController = documentController; } else if documentController.presentOptionsMenu(from: self.superview?.convert(self.frame, to: self.superview?.superview) ?? CGRect.zero, in: self.self, animated: true) { self.documentController = documentController; } } func download(for item: ConversationEntry) { guard let item = self.item, case .attachment(let url, _) = item.payload else { return; } _ = DownloadManager.instance.download(item: item, url: url, maxSize: Int64.max); (self.linkView as? AttachmentInfoView)?.progress(show: true); } private func downloadOrOpen() { guard let item = self.item else { return; } if let localUrl = DownloadStore.instance.url(for: "\(item.id)") { // let tmpUrl = FileManager.default.temporaryDirectory.appendingPathComponent(localUrl.lastPathComponent); // try? FileManager.default.copyItem(at: localUrl, to: tmpUrl); open(url: localUrl, preview: true); } else { let alert = UIAlertController(title: NSLocalizedString("Download", comment: "confirmation dialog title"), message: NSLocalizedString("File is not available locally. Should it be downloaded?", comment: "confirmation dialog body"), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("Yes", comment: "button label"), style: .default, handler: { (action) in self.download(for: item); })) alert.addAction(UIAlertAction(title: NSLocalizedString("No", comment: "button label"), style: .cancel, handler: nil)); if let controller = (UIApplication.shared.delegate as? AppDelegate)?.window?.rootViewController { controller.present(alert, animated: true, completion: nil); } } } class AttachmentInfoView: UIView, AVAudioPlayerDelegate { let iconView: ImageAttachmentPreview; let filename: UILabel; let details: UILabel; let actionButton: UIButton; private var viewType: ViewType = .none { didSet { guard viewType != oldValue else { return; } switch oldValue { case .none: break; case .audioFile: NSLayoutConstraint.deactivate(audioFileViewConstraints); case .file: NSLayoutConstraint.deactivate(fileViewConstraints); case .imagePreview: NSLayoutConstraint.deactivate(imagePreviewConstraints); } switch viewType { case .none: break; case .audioFile: NSLayoutConstraint.activate(audioFileViewConstraints); case .file: NSLayoutConstraint.activate(fileViewConstraints); case .imagePreview: NSLayoutConstraint.activate(imagePreviewConstraints); } iconView.contentMode = viewType == .imagePreview ? .scaleAspectFill : .scaleAspectFit; iconView.isImagePreview = viewType == .imagePreview; } } private var fileViewConstraints: [NSLayoutConstraint] = []; private var imagePreviewConstraints: [NSLayoutConstraint] = []; private var audioFileViewConstraints: [NSLayoutConstraint] = []; private static var labelFont: UIFont { let font = UIFont.preferredFont(forTextStyle: .headline); return font.withSize(font.pointSize - 2); } private static var detailsFont: UIFont { let font = UIFont.preferredFont(forTextStyle: .subheadline); return font.withSize(font.pointSize - 2); } private var fileUrl: URL?; override init(frame: CGRect) { iconView = ImageAttachmentPreview(frame: .zero); iconView.clipsToBounds = true iconView.image = UIImage(named: "defaultAvatar")!; iconView.translatesAutoresizingMaskIntoConstraints = false; iconView.setContentHuggingPriority(.defaultHigh, for: .vertical); iconView.setContentHuggingPriority(.defaultHigh, for: .horizontal); iconView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical); iconView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal); filename = UILabel(frame: .zero); filename.numberOfLines = 0 filename.font = AttachmentInfoView.labelFont//.font = UIFont.systemFont(ofSize: UIFont.systemFontSize, weight: .semibold); // filename.adjustsFontForContentSizeCategory = true; filename.translatesAutoresizingMaskIntoConstraints = false; filename.setContentHuggingPriority(.defaultHigh, for: .horizontal); filename.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal); filename.setContentCompressionResistancePriority(.defaultLow, for: .vertical) details = UILabel(frame: .zero); details.font = AttachmentInfoView.detailsFont// UIFont.systemFont(ofSize: UIFont.systemFontSize - 2, weight: .regular); // details.adjustsFontForContentSizeCategory = true; details.textColor = UIColor.secondaryLabel; details.translatesAutoresizingMaskIntoConstraints = false; details.setContentHuggingPriority(.defaultHigh, for: .horizontal) details.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal); details.numberOfLines = 0; actionButton = UIButton.systemButton(with: UIImage(systemName: "play.circle.fill")!, target: nil, action: nil); actionButton.translatesAutoresizingMaskIntoConstraints = false; actionButton.tintColor = UIColor(named: "tintColor"); super.init(frame: frame); self.clipsToBounds = true self.translatesAutoresizingMaskIntoConstraints = false; self.isOpaque = false; addSubview(iconView); addSubview(filename); addSubview(details); addSubview(actionButton); fileViewConstraints = [ iconView.heightAnchor.constraint(equalToConstant: 30), iconView.widthAnchor.constraint(equalTo: iconView.heightAnchor), iconView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 10), iconView.centerYAnchor.constraint(equalTo: self.centerYAnchor), iconView.topAnchor.constraint(greaterThanOrEqualTo: self.topAnchor, constant: 8), // iconView.bottomAnchor.constraint(lessThanOrEqualTo: self.bottomAnchor, constant: -8), filename.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 10), filename.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), filename.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor, constant: -10), details.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 10), details.topAnchor.constraint(equalTo: filename.bottomAnchor, constant: 4), details.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -8), // -- this is causing issue with progress indicatior!! details.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor, constant: -10), details.heightAnchor.constraint(equalTo: filename.heightAnchor), actionButton.heightAnchor.constraint(equalToConstant: 0), actionButton.widthAnchor.constraint(equalToConstant: 0), actionButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: 0), actionButton.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: 0) ]; audioFileViewConstraints = [ iconView.heightAnchor.constraint(equalToConstant: 30), iconView.widthAnchor.constraint(equalTo: iconView.heightAnchor), iconView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 10), iconView.centerYAnchor.constraint(equalTo: self.centerYAnchor), iconView.topAnchor.constraint(greaterThanOrEqualTo: self.topAnchor, constant: 8), // iconView.bottomAnchor.constraint(lessThanOrEqualTo: self.bottomAnchor, constant: -8), filename.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 10), filename.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), filename.trailingAnchor.constraint(lessThanOrEqualTo: self.actionButton.leadingAnchor, constant: -10), details.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 10), details.topAnchor.constraint(equalTo: filename.bottomAnchor, constant: 4), details.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -8), // -- this is causing issue with progress indicatior!! details.trailingAnchor.constraint(lessThanOrEqualTo: self.actionButton.leadingAnchor, constant: -10), actionButton.heightAnchor.constraint(equalToConstant: 30), actionButton.widthAnchor.constraint(equalTo: actionButton.heightAnchor), actionButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -10), actionButton.centerYAnchor.constraint(equalTo: self.centerYAnchor), actionButton.topAnchor.constraint(greaterThanOrEqualTo: self.topAnchor, constant: 8) ]; imagePreviewConstraints = [ iconView.widthAnchor.constraint(lessThanOrEqualToConstant: 350), iconView.heightAnchor.constraint(lessThanOrEqualToConstant: 350), iconView.widthAnchor.constraint(lessThanOrEqualTo: self.widthAnchor), iconView.heightAnchor.constraint(lessThanOrEqualTo: self.widthAnchor), iconView.leadingAnchor.constraint(equalTo: self.leadingAnchor), iconView.topAnchor.constraint(equalTo: self.topAnchor), iconView.trailingAnchor.constraint(equalTo: self.trailingAnchor), filename.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 16), filename.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 8), filename.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor, constant: -16), details.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 16), details.topAnchor.constraint(equalTo: filename.bottomAnchor, constant: 4), details.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -8), details.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor, constant: -16), details.heightAnchor.constraint(equalTo: filename.heightAnchor), actionButton.heightAnchor.constraint(equalToConstant: 0), actionButton.widthAnchor.constraint(equalToConstant: 0), actionButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: 0), actionButton.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: 0) ]; actionButton.addTarget(self, action: #selector(actionTapped(_:)), for: .touchUpInside); } required init?(coder: NSCoder) { return nil; } func prepareForReuse() { self.stopPlayingAudio(); } override func draw(_ rect: CGRect) { let path = UIBezierPath(roundedRect: rect, cornerRadius: 10); path.addClip(); UIColor.secondarySystemFill.setFill(); path.fill(); super.draw(rect); } static let timeFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter(); formatter.unitsStyle = .abbreviated; formatter.zeroFormattingBehavior = .dropAll; formatter.allowedUnits = [.minute,.second] return formatter; }(); func set(item: ConversationEntry, url: String, appendix: ChatAttachmentAppendix) { self.fileUrl = DownloadStore.instance.url(for: "\(item.id)"); if let fileUrl = self.fileUrl { filename.text = fileUrl.lastPathComponent; let fileSize = fileSizeToString(try! FileManager.default.attributesOfItem(atPath: fileUrl.path)[.size] as? UInt64); if let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileUrl.pathExtension as CFString, nil)?.takeRetainedValue(), let typeName = UTTypeCopyDescription(uti)?.takeRetainedValue() as String? { details.text = "\(typeName) - \(fileSize)"; if UTTypeConformsTo(uti, kUTTypeImage) { self.viewType = .imagePreview; iconView.image = UIImage(contentsOfFile: fileUrl.path)!; } else if UTTypeConformsTo(uti, kUTTypeAudio) { self.viewType = .audioFile; let asset = AVURLAsset(url: fileUrl); asset.loadValuesAsynchronously(forKeys: ["duration"], completionHandler: { DispatchQueue.main.async { guard self.fileUrl == fileUrl else { return; } if asset.duration != .invalid && asset.duration != .zero { let length = CMTimeGetSeconds(asset.duration); if let lengthStr = AttachmentInfoView.timeFormatter.string(from: length) { self.details.text = "\(typeName) - \(fileSize) - \(lengthStr)"; } } } }); iconView.image = UIImage.icon(forUTI: uti as String) ?? UIImage.icon(forFile: fileUrl, mimeType: appendix.mimetype); } else { self.viewType = .file; iconView.image = UIImage.icon(forFile: fileUrl, mimeType: appendix.mimetype); } } else if let mimetype = appendix.mimetype, let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimetype as CFString, nil)?.takeRetainedValue(), let typeName = UTTypeCopyDescription(uti)?.takeRetainedValue() as String? { details.text = "\(typeName) - \(fileSize)"; iconView.image = UIImage.icon(forUTI: uti as String) ?? UIImage.icon(forFile: fileUrl, mimeType: appendix.mimetype); self.viewType = .file; } else { details.text = String.localizedStringWithFormat(NSLocalizedString("File - %@", comment: "file size label"), fileSize); iconView.image = UIImage.icon(forFile: fileUrl, mimeType: appendix.mimetype); self.viewType = .file; } } else { let filename = appendix.filename ?? URL(string: url)?.lastPathComponent ?? ""; if filename.isEmpty { self.filename.text = NSLocalizedString("Unknown file", comment: "unknown file label"); } else { self.filename.text = filename; } if let size = appendix.filesize { if let mimetype = appendix.mimetype, let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimetype as CFString, nil)?.takeRetainedValue(), let typeName = UTTypeCopyDescription(uti)?.takeRetainedValue() as String? { let fileSize = size >= 0 ? fileSizeToString(UInt64(size)) : ""; details.text = "\(typeName) - \(fileSize)"; iconView.image = UIImage.icon(forUTI: uti as String); } else { details.text = String.localizedStringWithFormat(NSLocalizedString("File - %@", comment: "file size label"),fileSizeToString(UInt64(size))); iconView.image = UIImage.icon(forUTI: "public.content"); } } else { details.text = "--"; iconView.image = UIImage.icon(forUTI: "public.content"); } self.viewType = .file; } } var progressView: UIActivityIndicatorView?; func progress(show: Bool) { guard show != (progressView != nil) else { return; } if show { let view = UIActivityIndicatorView(style: .medium); view.translatesAutoresizingMaskIntoConstraints = false; self.addSubview(view); NSLayoutConstraint.activate([ view.leadingAnchor.constraint(greaterThanOrEqualTo: filename.trailingAnchor, constant: 8), view.leadingAnchor.constraint(greaterThanOrEqualTo: details.trailingAnchor, constant: 8), view.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12), view.bottomAnchor.constraint(equalTo: self.bottomAnchor), view.topAnchor.constraint(lessThanOrEqualTo: self.topAnchor) ]) self.progressView = view; view.startAnimating(); } else if let view = progressView { view.stopAnimating(); self.progressView = nil; view.removeFromSuperview(); } } func fileSizeToString(_ sizeIn: UInt64?) -> String { guard let size = sizeIn else { return ""; } let formatter = ByteCountFormatter(); formatter.countStyle = .file; return formatter.string(fromByteCount: Int64(size)); } enum ViewType { case none case file case imagePreview case audioFile } private var audioPlayer: AVAudioPlayer?; private func startPlayingAudio() { stopPlayingAudio(); guard let fileUrl = self.fileUrl else { return; } do { try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default); try? AVAudioSession.sharedInstance().setActive(true); audioPlayer = try AVAudioPlayer(contentsOf: fileUrl); audioPlayer?.delegate = self; audioPlayer?.volume = 1.0; audioPlayer?.play(); self.actionButton.setImage(UIImage(systemName: "pause.circle.fill")!, for: .normal); } catch { self.stopPlayingAudio(); } } private func stopPlayingAudio() { audioPlayer?.stop(); audioPlayer = nil; self.actionButton.setImage(UIImage(systemName: "play.circle.fill"), for: .normal); } func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { audioPlayer?.stop(); audioPlayer = nil; self.actionButton.setImage(UIImage(systemName: "play.circle.fill"), for: .normal); } @objc func actionTapped(_ sender: Any) { if audioPlayer == nil { self.startPlayingAudio(); } else { self.stopPlayingAudio(); } } } } class ImageAttachmentPreview: UIImageView { var isImagePreview: Bool = false { didSet { if isImagePreview != oldValue { if isImagePreview { self.layer.cornerRadius = 10; self.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]; } else { self.layer.cornerRadius = 0; self.layer.maskedCorners = []; } } } } override init(frame: CGRect) { super.init(frame: frame); } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension FileManager { public func fileExtension(forUTI utiString: String) -> String? { guard let cfFileExtension = UTTypeCopyPreferredTagWithClass(utiString as CFString, kUTTagClassFilenameExtension)?.takeRetainedValue() else { return nil } return cfFileExtension as String } } extension UIImage { class func icon(forFile url: URL, mimeType: String?) -> UIImage? { let controller = UIDocumentInteractionController(url: url); if mimeType != nil, let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType! as CFString, nil)?.takeRetainedValue() as String? { controller.uti = uti; } if controller.icons.count == 0 { controller.uti = "public.data"; } let icons = controller.icons; return icons.last; } class func icon(forUTI utiString: String) -> UIImage? { let controller = UIDocumentInteractionController(url: URL(fileURLWithPath: "temp.file")); controller.uti = utiString; if controller.icons.count == 0 { controller.uti = "public.data"; } let icons = controller.icons; return icons.last; } } ================================================ FILE: SiskinIM/conversation/BaseChatTableViewCell.swift ================================================ // // BaseChatTableViewCell.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Combine class BaseChatTableViewCellFormatter { fileprivate static let todaysFormatter = ({()-> DateFormatter in var f = DateFormatter(); f.dateStyle = .none; f.timeStyle = .short; return f; })(); fileprivate static let defaultFormatter = ({()-> DateFormatter in var f = DateFormatter(); f.dateFormat = DateFormatter.dateFormat(fromTemplate: "dd.MM", options: 0, locale: NSLocale.current); // f.timeStyle = .NoStyle; return f; })(); fileprivate static let fullFormatter = ({()-> DateFormatter in var f = DateFormatter(); f.dateFormat = DateFormatter.dateFormat(fromTemplate: "dd.MM.yyyy", options: 0, locale: NSLocale.current); // f.timeStyle = .NoStyle; return f; })(); } class BaseChatTableViewCell: UITableViewCell, UIDocumentInteractionControllerDelegate { @IBOutlet var avatarView: AvatarView? @IBOutlet var nicknameView: UILabel?; @IBOutlet var timestampView: UILabel? @IBOutlet var stateView: UILabel?; private var cancellables: Set = []; func formatTimestamp(_ ts: Date) -> String { let flags: Set = [.day, .year]; let components = Calendar.current.dateComponents(flags, from: ts, to: Date()); if (components.day! < 1) { return BaseChatTableViewCellFormatter.todaysFormatter.string(from: ts); } if (components.year! != 0) { return BaseChatTableViewCellFormatter.fullFormatter.string(from: ts); } else { return BaseChatTableViewCellFormatter.defaultFormatter.string(from: ts); } } private static let relativeForamtter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter(); formatter.dateTimeStyle = .named; formatter.unitsStyle = .short; return formatter; }(); static func formatTimestamp(_ ts: Date, _ now: Date, prefix: String?) -> String { let timestamp = formatTimestamp(ts, now); if let prefix = prefix { return "\(prefix) \(timestamp)"; } else { return timestamp; } } static func formatTimestamp(_ ts: Date, _ now: Date) -> String { let flags: Set = [.minute, .hour, .day, .year]; var components = Calendar.current.dateComponents(flags, from: now, to: ts); if (components.day! >= -1) { components.second = 0; return relativeForamtter.localizedString(from: components); } if (components.year! != 0) { return BaseChatTableViewCellFormatter.fullFormatter.string(from: ts); } else { return BaseChatTableViewCellFormatter.defaultFormatter.string(from: ts); } } override func awakeFromNib() { super.awakeFromNib() // Initialization code if avatarView != nil { avatarView!.layer.masksToBounds = true; avatarView!.layer.cornerRadius = avatarView!.frame.height / 2; } stateView?.textColor = UIColor.secondaryLabel; nicknameView?.textColor = UIColor.secondaryLabel; nicknameView?.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: UIFont(descriptor: UIFont.preferredFont(forTextStyle: .footnote).fontDescriptor.withSymbolicTraits(.traitBold)!, size: 0)); } override func setSelected(_ selected: Bool, animated: Bool) { if selected { let colors = contentView.subviews.map({ it -> UIColor in it.backgroundColor ?? UIColor.clear }); super.setSelected(selected, animated: animated) selectedBackgroundView = UIView(); contentView.subviews.enumerated().forEach { (offset, view) in if view .responds(to: #selector(setHighlighted(_:animated:))) { view.setValue(false, forKey: "highlighted"); } view.backgroundColor = colors[offset]; } } else { super.setSelected(selected, animated: animated); selectedBackgroundView = nil; } // Configure the view for the selected state } private var avatar: Avatar?; func set(item: ConversationEntry) { cancellables.removeAll(); if let avatarView = self.avatarView, let avatar = item.sender.avatar(for: item.conversation) { let name = item.sender.nickname; avatar.avatarPublisher.receive(on: DispatchQueue.main).sink(receiveValue: { image in avatarView.set(name: name, avatar: image); }).store(in: &cancellables); self.avatar = avatar; } else { self.avatar = nil; } if nicknameView != nil { switch item.options.recipient { case .none: self.nicknameView?.text = item.sender.nickname; case .occupant(let nickname): let val = NSMutableAttributedString(string: item.state.direction == .incoming ? "\(NSLocalizedString("From", comment: "conversation log groupchat direction label")) \(item.sender.nickname!) " : "\(NSLocalizedString("To", comment: "conversation log groupchat direction label")) \(nickname) "); let font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: UIFont(descriptor: UIFont.preferredFont(forTextStyle: .footnote).fontDescriptor.withSymbolicTraits([.traitBold, .traitItalic])!, size: 0)); val.append(NSAttributedString(string: " \(NSLocalizedString("(private message)", comment: "conversation log groupchat direction label"))", attributes: [.font: font, .foregroundColor: UIColor.secondaryLabel])); self.nicknameView?.attributedText = val; } } var timestampPrefix: String? = nil; switch item.options.encryption { case .decrypted, .notForThisDevice, .decryptionFailed: timestampPrefix = "\u{1F512} "; default: break; } if let timestampView = self.timestampView { let timestamp = item.timestamp; CurrentTimePublisher.publisher.map({ now in BaseChatTableViewCell.formatTimestamp(timestamp, now, prefix: timestampPrefix) }).assign(to: \.text, on: timestampView).store(in: &cancellables); } if stateView != nil { switch item.state { case .none: self.stateView?.text = nil; case .incoming_error(_, _): self.stateView?.text = "\u{203c}"; case .outgoing_error(_, _): self.stateView?.text = "\u{203c}"; case .outgoing(let state): switch state { case .unsent: self.stateView?.text = "\u{1f4e4}"; case .delivered: self.stateView?.text = "\u{2713}"; case .displayed: self.stateView?.text = "🔖"; case .sent: self.stateView?.text = nil; } case .incoming(_): self.stateView?.text = nil; } // if item.state.direction == .outgoing { // timestampView?.textColor = originalTimestampColor; // if item.state.isError { // timestampView?.textColor = UIColor.red; // timestamp = "\(timestamp) Not delivered\u{203c}"; // } else if item.state == .outgoing_delivered { // timestamp = "\(timestamp) \u{2713}"; // } // } } if item.state.isError { if item.state.direction == .outgoing { self.accessoryType = .detailButton; self.tintColor = UIColor.red; } else { self.accessoryType = .none; self.tintColor = stateView?.tintColor; } } else { self.accessoryType = .none; self.tintColor = stateView?.tintColor; } self.stateView?.textColor = item.state.isError && item.state.direction == .incoming ? UIColor.red : UIColor.secondaryLabel; self.timestampView?.textColor = item.state.isError && item.state.direction == .incoming ? UIColor.red : UIColor.secondaryLabel; } @objc func actionMore(_ sender: UIMenuController) { NotificationCenter.default.post(name: NSNotification.Name("tableViewCellShowEditToolbar"), object: self); } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { return super.canPerformAction(action, withSender: sender) || action == #selector(actionMore(_:)); } override func didTransition(to state: UITableViewCell.StateMask) { super.didTransition(to: state); UIView.setAnimationsEnabled(false); if state.contains(.showingEditControl) { for view in self.subviews { if view != self.contentView { view.transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0); } } } UIView.setAnimationsEnabled(true); } } ================================================ FILE: SiskinIM/conversation/ChatTableViewCell.swift ================================================ // // ChatTableViewCell.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class ChatTableViewCell: BaseChatTableViewCell, UITextViewDelegate { @IBOutlet var messageTextView: MessageTextView! var id: Int = 0; override var backgroundColor: UIColor? { didSet { self.messageTextView.backgroundColor = self.backgroundColor; } } func setRetracted(item: ConversationEntry) { set(item: item); let msg = NSAttributedString(string: NSLocalizedString("(this message has been removed)", comment: "conversation log label"), attributes: [.font: Markdown.font(withTextStyle: .body, andTraits: [.traitItalic, .traitBold]), .foregroundColor: UIColor.secondaryLabel]); self.messageTextView.attributedText = msg; } override func set(item: ConversationEntry) { super.set(item: item); id = item.id; } func set(item: ConversationEntry, message inMessage: String, correctionTimestamp: Date?, nickname: String? = nil) { messageTextView.textView.delegate = self; set(item: item); let accessibilityLabel = NSMutableAttributedString(string: "") if let nickname = self.nicknameView?.text { accessibilityLabel.append(NSAttributedString(string: "\(nickname)")) } if let timestamp = self.timestampView?.text { accessibilityLabel.append(NSAttributedString(string: " \(timestamp)")) } if correctionTimestamp != nil, case .incoming(_) = item.state { self.stateView?.text = "✏️\(self.stateView!.text ?? "")"; } let message = messageBody(item: item, message: inMessage); let attrText = NSMutableAttributedString(string: message); if let detect = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue | NSTextCheckingResult.CheckingType.phoneNumber.rawValue | NSTextCheckingResult.CheckingType.address.rawValue | NSTextCheckingResult.CheckingType.date.rawValue) { let matches = detect.matches(in: message, options: .reportCompletion, range: NSMakeRange(0, message.count)); for match in matches { var url: URL? = nil; if match.url != nil { url = match.url; } if match.phoneNumber != nil { url = URL(string: "tel:\(match.phoneNumber!.replacingOccurrences(of: " ", with: "-"))"); } if match.addressComponents != nil { if let query = match.addressComponents!.values.joined(separator: ",").addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) { url = URL(string: "http://maps.apple.com/?q=\(query)"); } } if match.date != nil { url = URL(string: "calshow:\(match.date!.timeIntervalSinceReferenceDate)"); } if let url = url { attrText.setAttributes([.link : url], range: match.range); } } } attrText.addAttribute(.foregroundColor, value: UIColor(named: "chatMessageText") as Any, range: NSRange(location: 0, length: attrText.length)); if Settings.enableMarkdownFormatting { Markdown.applyStyling(attributedString: attrText, defTextStyle: .body, showEmoticons: Settings.showEmoticons); } else { attrText.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .subheadline), range: NSRange(location: 0, length: attrText.length)); attrText.fixAttributes(in: NSRange(location: 0, length: attrText.length)); } self.messageTextView.attributedText = attrText; if accessibilityLabel.length > 0 { accessibilityLabel.append(NSAttributedString(string: " ")); } accessibilityLabel.append(attrText); self.accessibilityAttributedLabel = accessibilityLabel; self.isAccessibilityElement = true // if item.state.isError { // if (self.messageTextView.text?.isEmpty ?? true), let error = item.error { // self.messageTextView.text = "Error: \(error)"; // } // if item.state.direction == .incoming { // self.messageTextView.textView.textColor = UIColor.red; // } // } else { // if item.encryption == .notForThisDevice || item.encryption == .decryptionFailed { // self.messageTextView.textView.textColor = UIColor(named: "chatMessageText"); // } // } } func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { UIApplication.shared.open(URL); return false; } fileprivate func messageBody(item: ConversationEntry, message: String) -> String { guard let msg = item.options.encryption.message() else { switch item.state { case .incoming_error(_, let errorMessage), .outgoing_error(_, let errorMessage): if let error = errorMessage { return "\(message)\n-----\n\(error)" } default: break; } return message; } return msg; } } // Helper function inserted by Swift 4.2 migrator. fileprivate func convertFromOptionalNSTextCheckingKeyDictionary(_ input: [NSTextCheckingKey: Any]?) -> [String: Any]? { guard let input = input else { return nil } return Dictionary(uniqueKeysWithValues: input.map {key, value in (key.rawValue, value)}) } // Helper function inserted by Swift 4.2 migrator. fileprivate func convertToOptionalNSAttributedStringKeyDictionary(_ input: [String: Any]?) -> [NSAttributedString.Key: Any]? { guard let input = input else { return nil } return Dictionary(uniqueKeysWithValues: input.map { key, value in (NSAttributedString.Key(rawValue: key), value)}) } // Helper function inserted by Swift 4.2 migrator. fileprivate func convertFromNSAttributedStringKey(_ input: NSAttributedString.Key) -> String { return input.rawValue } ================================================ FILE: SiskinIM/conversation/ChatTableViewMarkerCell.swift ================================================ // // ChatTableViewMarkerCell.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Combine class ChatTableViewMarkerCell: UITableViewCell { @IBOutlet var label: UILabel!; @IBOutlet var avatars: UIStackView!; private var cancellables: Set = []; func set(item: ConversationEntry, type: ChatMarker.MarkerType, senders: [ConversationEntrySender]) { cancellables.removeAll(); for view in self.avatars.arrangedSubviews { view.removeFromSuperview(); } for idx in 0.. 3 ? "+\(senders.count - 3) " : ""; self.label?.text = "\(prefix)\(type.label)"; } } ================================================ FILE: SiskinIM/conversation/ChatTableViewSystemCell.swift ================================================ // // ChatTableViewSystemCell.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class ChatTableViewSystemCell: UITableViewCell { @IBOutlet var messageView: UILabel! } class ChatTableViewMeCell: UITableViewCell { @IBOutlet var messageView: MessageTextView! func set(item: ConversationEntry, message msg: String) { let nickname = item.sender.nickname ?? "SOMEONE:"; let preferredFont = UIFont.preferredFont(forTextStyle: .subheadline); let message = NSMutableAttributedString(string: "\(nickname) ", attributes: [.font: UIFont(descriptor: preferredFont.fontDescriptor.withSymbolicTraits([.traitBold,.traitItalic])!, size: 0), .foregroundColor: UIColor.secondaryLabel]); message.append(NSAttributedString(string: "\(msg.dropFirst(4))", attributes: [.font: UIFont(descriptor: preferredFont.fontDescriptor.withSymbolicTraits(.traitItalic)!, size: 0), .foregroundColor: UIColor.secondaryLabel])); self.messageView.attributedText = message; self.accessibilityAttributedLabel = message; self.isAccessibilityElement = true } } ================================================ FILE: SiskinIM/conversation/ConversationDataSource.swift ================================================ // // ConversationDataSource.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Combine import Martin import TigaseLogging protocol ConversationDataSourceDelegate: AnyObject { var conversation: Conversation! { get } func beginUpdates(); func endUpdates(); func itemsAdded(at: IndexSet, initial: Bool); func itemsUpdated(forRowIndexes: IndexSet); func itemUpdated(indexPath: IndexPath); func itemsRemoved(at: IndexSet); func itemsReloaded(); func isVisible(row: Int) -> Bool; func scrollRowToVisible(_ row: Int); func markAsReadUpToNewestVisibleRow(); } extension ConversationDataSourceDelegate { func update(_ block: (ConversationDataSourceDelegate)->Void) { beginUpdates(); block(self); endUpdates(); } } public enum ConversationLoadType { case with(id: Int, overhead: Int) case unread(overhead: Int) case before(entry: ConversationEntry, limit: Int) var markUnread: Bool { switch self { case .unread(_): return true; default: return false; } } } class ConversationDataSource { enum State { case uninitialized case loading case loaded } private var store: [ConversationEntry] = []; private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ConversationDataSource"); private let queue = DispatchQueue(label: "chat_datasource"); weak var delegate: ConversationDataSourceDelegate? { didSet { delegate?.conversation.markersPublisher.receive(on: self.queue).sink(receiveValue: { [weak self] markers in self?.update(markers: markers); }).store(in: &cancellables); } } public var defaultPageSize = 80; private var state: State = .uninitialized; var count: Int { return store.count; } private var oldestEntry: ConversationEntry?; private var entries: [ConversationEntry] = []; private var entriesCount: Int = 0; private var markers: [ConversationEntry] = []; private var unreads: [ConversationEntry] = []; private var cancellables: Set = []; private var knownItems: Set = []; init() { NotificationCenter.default.addObserver(self, selector: #selector(messageNew), name: DBChatHistoryStore.MESSAGE_NEW, object: nil); NotificationCenter.default.addObserver(self, selector: #selector(messageUpdated(_:)), name: DBChatHistoryStore.MESSAGE_UPDATED, object: nil); NotificationCenter.default.addObserver(self, selector: #selector(messageRemoved(_:)), name: DBChatHistoryStore.MESSAGE_REMOVED, object: nil); Settings.$linkPreviews.dropFirst().sink(receiveValue: { [weak self] _ in guard let that = self else { return; } that.loadItems(.unread(overhead: that.count)) }).store(in: &cancellables); } @objc fileprivate func messageNew(_ notification: NSNotification) { guard let item = notification.object as? ConversationEntry else { return; } guard let conversation = delegate?.conversation else { return; } guard conversation.id == (item.conversation as? Conversation)?.id else { return; } self.add(item: item); } @objc fileprivate func messageUpdated(_ notification: Notification) { guard let item = notification.object as? ConversationEntry else { return; } guard let conversation = delegate?.conversation else { return; } guard conversation.id == (item.conversation as? Conversation)?.id else { return; } self.update(item: item); } @objc fileprivate func messageRemoved(_ notification: Notification) { guard let item = notification.object as? ConversationEntry else { return; } guard let conversation = delegate?.conversation else { return; } guard conversation.id == (item.conversation as? Conversation)?.id else { return; } remove(item: item); } func refreshDataNoReload() { queue.async { let store = self.store; DispatchQueue.main.sync { self.store = store; self.delegate?.itemsReloaded(); } } } // should be called from the main dispatch queue!! func loadItems(_ type: ConversationLoadType) { // do not load if load is already in progress.. guard state != .loading, let conversation = self.delegate?.conversation else { return; } let initialLoad = self.state == .uninitialized; state = .loading; queue.async { let items: [ConversationEntry] = conversation.loadItems(type); self.add(items: items, scrollTo: .from(loadType: type), initial: initialLoad, completionHandler: { self.state = .loaded; }); } } private struct ChatMarkerKey: Hashable { let type: ChatMarker.MarkerType; let timestamp: Date; } // call only from local dispatch queue private func updateStore(scrollTo: ScrollTo = .none, completionHandler: (()->Void)? = nil) { let entriesCount = entries.count; let oldStore = store; let newStore = (entries + unreads + markers).sorted(); var scrollToIdx: Int?; switch scrollTo { case .oldestUnread: if let lastUnreadIdx = newStore.lastIndex(where: { $0.state.isUnread }) { scrollToIdx = lastUnreadIdx; } case .message(let id): scrollToIdx = newStore.firstIndex(where: { $0.id == id }); case .none: break; } let oldestEntry = newStore.last(where: { $0.sender != .none }); let changes = newStore.calculateChanges(from: oldStore); let initial = self.state != .loaded; DispatchQueue.main.sync { self.store = newStore; self.entriesCount = entriesCount; self.oldestEntry = oldestEntry; completionHandler?(); if initial { delegate?.itemsReloaded() } else { self.delegate?.update({ delegate in // it looks like insert/removed are not detected at all! delegate.itemsRemoved(at: changes.removed); delegate.itemsAdded(at: changes.inserted, initial: initial); }) } if let scrollToIdx = scrollToIdx { self.delegate?.scrollRowToVisible(scrollToIdx); } else { self.delegate?.markAsReadUpToNewestVisibleRow(); } } } private func update(markers: [ChatMarker]) { guard let conversation = self.delegate?.conversation else { return; } let grouped: [ChatMarkerKey: [ConversationEntrySender]] = markers.reduce(into: [:], { result, marker in result[ChatMarkerKey(type: marker.type, timestamp: marker.timestamp), default: []] += [marker.sender]; }) self.markers = grouped.map({ k, v in return ConversationEntry(id: Int.max, conversation: conversation, timestamp: k.timestamp, state: .none, sender: .none, payload: .marker(type: k.type, senders: v), options: .none) }); self.updateStore(); } private func add(item: ConversationEntry) { queue.async { self.add(items: [item], scrollTo: .none, completionHandler: nil); } } func getItem(at row: Int) -> ConversationEntry? { guard store.count > row && row >= 0 else { return nil; } let store = self.store; let item = store[row]; // load more if remaining equals ChatMarkers! if row >= (entriesCount - 1) { if let oldestEntry = self.oldestEntry { self.loadItems(.before(entry: oldestEntry, limit: self.defaultPageSize)) } } return item; } func getItem(withId id: Int) -> ConversationEntry? { return self.store.first { (item) -> Bool in return item.id == id; }; } func getItems(fromId: Int, toId: Int, inRange: NSRange) -> [ConversationEntry] { let store = self.store; guard store.count > inRange.upperBound-1 else { return []; } let sublist = store[inRange.lowerBound.. Bool in return item.id == fromId || item.id == toId; }.sorted { (i1, i2) -> Bool in return i1.timestamp.compare(i2.timestamp) == .orderedAscending; }; let start = edges[0].timestamp; let end = edges.count == 1 ? start : edges[1].timestamp; return sublist.filter { (i) -> Bool in (i.timestamp.compare(start) != .orderedAscending) && (i.timestamp.compare(end) != .orderedDescending) }.sorted { (i1, i2) -> Bool in return i1.timestamp.compare(i2.timestamp) == .orderedAscending; }; } func remove(item: ConversationEntry) { queue.async { guard self.knownItems.contains(item.id) else { return; } self.knownItems.remove(item.id); self.entries = self.entries.filter({ $0.id == item.id }); self.updateStore(); } } func update(item: ConversationEntry) { queue.async { var entries = self.entries.filter({ $0.id != item.id }); if !self.knownItems.contains(item.id) { self.knownItems.insert(item.id); } entries.append(item); self.entries = entries self.updateStore(); } } func trimStore() { guard store.count > 100 else { return; } queue.async { guard self.entries.count > 100 else { return; } self.entries = Array(self.entries.sorted()[0..<100]); self.knownItems = Set(self.entries.map({ $0.id })); self.updateStore(); } } func isAnyMatching(_ fn: (ConversationEntry)->Bool, in range: Range) -> Bool { for i in range { let item = store[i] if fn(item) { return true; } } return false; } // should be called from internal queue! private func add(items: [ConversationEntry], scrollTo: ScrollTo, initial: Bool = false, completionHandler: (()->Void)?) { let start = Date(); let newItems = items.filter({ !self.knownItems.contains($0.id) }); guard !newItems.isEmpty else { DispatchQueue.main.async { completionHandler?(); } logger.debug("skipped adding rows as no rows were loadad!") return; } if case .oldestUnread = scrollTo { if entries.isEmpty,let firstUnread = newItems.last(where: { $0.state.isUnread }) { self.unreads = [.init(id: Int.min, conversation: firstUnread.conversation, timestamp: firstUnread.timestamp, state: .none, sender: .none, payload: .unreadMessages, options: .none)]; } } self.entries = self.entries + newItems; self.knownItems = Set(self.entries.map({ $0.id })); // how to calculate where to scroll before main dispatch queue is fired? self.updateStore(scrollTo: scrollTo, completionHandler: { self.logger.debug("items added in: \(Date().timeIntervalSince(start))") completionHandler?(); }); } enum ScrollTo { case none case oldestUnread case message(withId: Int) static func from(loadType: ConversationLoadType) -> ScrollTo { switch loadType { case .unread(_): return .oldestUnread; case .with(let id, _): return .message(withId: id); case .before(_, _): return .none; } } } } extension ConversationEntry { func isMessage() -> Bool { switch payload { case .message(_, _): return true; default: return false; } } } extension ConversationEntry { func isMarker() -> Bool { switch payload { case .marker(_, _): return true; default: return false; } } } ================================================ FILE: SiskinIM/conversation/ConversationLogController.swift ================================================ // // ConversationLogController.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Combine import CoreLocation import MapKit class ConversationLogController: UIViewController, ConversationDataSourceDelegate, UITableViewDataSource { public static let REFRESH_CELL = Notification.Name("ConversationCellRefresh"); private let firstRowIndexPath = IndexPath(row: 0, section: 0); @IBOutlet var tableView: UITableView!; let dataSource = ConversationDataSource(); var conversation: Conversation!; weak var conversationLogDelegate: ConversationLogDelegate?; var refreshControl: UIRefreshControl?; private let newestVisibleDateSubject = PassthroughSubject(); private var cancellables: Set = []; override func viewDidLoad() { super.viewDidLoad(); dataSource.delegate = self; tableView.rowHeight = UITableView.automaticDimension; tableView.estimatedRowHeight = 160.0; tableView.separatorStyle = .none; tableView.transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0); if let refreshControl = self.refreshControl { tableView.addSubview(refreshControl); } conversationLogDelegate?.initialize(tableView: self.tableView); tableView.dataSource = self; NotificationCenter.default.addObserver(self, selector: #selector(showEditToolbar), name: NSNotification.Name("tableViewCellShowEditToolbar"), object: nil); NotificationCenter.default.addObserver(self, selector: #selector(refreshCell(_:)), name: ConversationLogController.REFRESH_CELL, object: nil); } override func viewWillAppear(_ animated: Bool) { if let conversation = self.conversation { XmppService.instance.$applicationState.filter({ $0 == .active }).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] _ in self?.markAsReadUpToNewestVisibleRow(); }).store(in: &cancellables); newestVisibleDateSubject.onlyGreater().throttledSink(for: 0.5, scheduler: DispatchQueue.main, receiveValue: { date in DBChatHistoryStore.instance.markAsRead(for: conversation, before: date); }); dataSource.loadItems(.unread(overhead: 50)); NotificationManager.instance.dismissAllNotifications(on: conversation.account, with: conversation.jid); } super.viewWillAppear(animated); } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated); hideEditToolbar(); } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated); self.markAsReadUpToNewestVisibleRow(); } func numberOfSections(in tableView: UITableView) -> Int { return 1; } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return dataSource.count; } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let item = dataSource.getItem(at: indexPath.row) else { return tableView.dequeueReusableCell(withIdentifier: "ChatTableViewCellIncoming", for: indexPath); } switch item.payload { case .unreadMessages: let cell: ChatTableViewSystemCell = tableView.dequeueReusableCell(withIdentifier: "ChatTableViewSystemCell", for: indexPath) as! ChatTableViewSystemCell; cell.messageView.text = NSLocalizedString("Unread messages", comment: "conversation log label"); cell.contentView.transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0); return cell; case .messageRetracted: let id = isContinuation(at: indexPath.row, for: item) ? "ChatTableViewMessageContinuationCell" : "ChatTableViewMessageCell"; let cell: ChatTableViewCell = tableView.dequeueReusableCell(withIdentifier: id, for: indexPath) as! ChatTableViewCell; cell.contentView.transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0); cell.setRetracted(item: item); // cell.setNeedsUpdateConstraints(); // cell.updateConstraintsIfNeeded(); return cell; case .message(let message, let correctionTimestamp): if message.starts(with: "/me") { let cell = tableView.dequeueReusableCell(withIdentifier: "ChatTableViewMeCell", for: indexPath) as! ChatTableViewMeCell; cell.contentView.transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0); cell.set(item: item, message: message); return cell; } else { let id = isContinuation(at: indexPath.row, for: item) ? "ChatTableViewMessageContinuationCell" : "ChatTableViewMessageCell"; let cell: ChatTableViewCell = tableView.dequeueReusableCell(withIdentifier: id, for: indexPath) as! ChatTableViewCell; cell.contentView.transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0); cell.set(item: item, message: message, correctionTimestamp: correctionTimestamp); // cell.setNeedsUpdateConstraints(); // cell.updateConstraintsIfNeeded(); return cell; } case .location(let location): let id = "ChatTableViewLocationCell"; let cell: LocationChatTableViewCell = tableView.dequeueReusableCell(withIdentifier: id, for: indexPath) as! LocationChatTableViewCell; cell.contentView.transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0); cell.set(item: item, location: location); return cell; case .linkPreview(let url): let id = "ChatTableViewLinkPreviewCell"; let cell: LinkPreviewChatTableViewCell = tableView.dequeueReusableCell(withIdentifier: id, for: indexPath) as! LinkPreviewChatTableViewCell; cell.contentView.transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0); cell.set(item: item, url: url); return cell; case .attachment(let url, let appendix): let id = isContinuation(at: indexPath.row, for: item) ? "ChatTableViewAttachmentContinuationCell" : "ChatTableViewAttachmentCell" ; let cell: AttachmentChatTableViewCell = tableView.dequeueReusableCell(withIdentifier: id, for: indexPath) as! AttachmentChatTableViewCell; cell.contentView.transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0); cell.set(item: item, url: url, appendix: appendix); // cell.setNeedsUpdateConstraints(); // cell.updateConstraintsIfNeeded(); return cell; case .invitation(let message, let appendix): let id = "ChatTableViewInvitationCell"; let cell: InvitationChatTableViewCell = tableView.dequeueReusableCell(withIdentifier: id, for: indexPath) as! InvitationChatTableViewCell; cell.contentView.transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0); cell.set(item: item, message: message, appendix: appendix); return cell; case .marker(let type, let senders): let cell = tableView.dequeueReusableCell(withIdentifier: "ChatTableViewMarkerCell", for: indexPath) as! ChatTableViewMarkerCell; cell.set(item: item, type: type, senders: senders); cell.contentView.transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0); return cell; default: return tableView.dequeueReusableCell(withIdentifier: "ChatTableViewCellIncoming", for: indexPath); } } private func getPreviousEntry(before row: Int) -> ConversationEntry? { guard row >= 0 && (row + 1) < dataSource.count else { return nil; } return dataSource.getItem(at: row + 1); } private func isContinuation(at row: Int, for entry: ConversationEntry) -> Bool { guard let prevEntry = getPreviousEntry(before: row) else { return false; } switch prevEntry.payload { case .messageRetracted, .message(_, _), .attachment(_, _): return entry.isMergeable(with: prevEntry); case .marker(_, _), .linkPreview(_): return isContinuation(at: row + 1, for: entry); default: return false; } } func beginUpdates() { tableView.beginUpdates(); } func endUpdates() { tableView.endUpdates(); } func itemsAdded(at rows: IndexSet, initial: Bool) { let paths = rows.map({ IndexPath(row: $0, section: 0) }); tableView.insertRows(at: paths, with: initial ? .none : .fade) } func itemsUpdated(forRowIndexes rows: IndexSet) { let paths = rows.map({ IndexPath(row: $0, section: 0) }); tableView.reloadRows(at: paths, with: .fade) markAsReadUpToNewestVisibleRow(); } func itemUpdated(indexPath: IndexPath) { tableView.deleteRows(at: [indexPath], with: .fade); tableView.insertRows(at: [indexPath], with: .fade); markAsReadUpToNewestVisibleRow(); } func isVisible(row: Int) -> Bool { return tableView.indexPathsForVisibleRows?.contains(where: { $0.row == row }) ?? false; } func scrollRowToVisible(_ row: Int) { tableView.scrollToRow(at: IndexPath(row: row, section: 0), at: .none, animated: true); } func itemsRemoved(at rows: IndexSet) { let paths = rows.map({ IndexPath(row: $0, section: 0)}); tableView.deleteRows(at: paths, with: .fade); } func itemsReloaded() { tableView.reloadData(); markAsReadUpToNewestVisibleRow(); } func scrollViewDidScroll(_ scrollView: UIScrollView) { //super.scrollViewDidScroll(scrollView); markAsReadUpToNewestVisibleRow(); } func markAsReadUpToNewestVisibleRow() { if let visibleRows = tableView.indexPathsForVisibleRows { if visibleRows.contains(IndexPath(row: 0, section: 0)) { self.dataSource.trimStore(); } if UIApplication.shared.applicationState == .active, let newestVisibleUnreadTimestamp = visibleRows.compactMap({ index -> Date? in guard let item = dataSource.getItem(at: index.row) else { return nil; } return item.timestamp; }).max() { newestVisibleDateSubject.send(newestVisibleUnreadTimestamp); } } } func reloadVisibleItems() { if let indexPaths = self.tableView.indexPathsForVisibleRows { self.tableView.reloadRows(at: indexPaths, with: .none); } } @objc func refreshCell(_ notification: Notification) { guard let cell = notification.object as? UITableViewCell, let idx = tableView.indexPath(for: cell) else { return; } tableView.reloadRows(at: [idx], with: .automatic); } private var tempRightBarButtonItem: UIBarButtonItem?; } extension ConversationLogController { private var withTimestamps: Bool { get { return Settings.copyMessagesWithTimestamps; } }; @objc func editCancelClicked() { hideEditToolbar(); } func showMap(item: ConversationEntry) { guard case let .location(coordinate) = item.payload else { return; } let placemark = MKPlacemark(coordinate: coordinate); let region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 2000, longitudinalMeters: 2000); let item = MKMapItem(placemark: placemark); item.openInMaps(launchOptions: [ MKLaunchOptionsMapCenterKey: NSValue(mkCoordinate: region.center), MKLaunchOptionsMapSpanKey: NSValue(mkCoordinateSpan: region.span) ]) } func copySelectedMessages() { copyMessageInt(paths: tableView.indexPathsForSelectedRows ?? []); hideEditToolbar(); } @objc func shareSelectedMessages() { shareMessageInt(paths: tableView.indexPathsForSelectedRows ?? []); hideEditToolbar(); } func copyMessageInt(paths: [IndexPath]) { getTextOfSelectedRows(paths: paths, withTimestamps: false) { (texts) in UIPasteboard.general.strings = texts; UIPasteboard.general.string = texts.joined(separator: "\n"); }; } func shareMessageInt(paths: [IndexPath]) { getTextOfSelectedRows(paths: paths, withTimestamps: withTimestamps) { (texts) in let text = texts.joined(separator: "\n"); let activityController = UIActivityViewController(activityItems: [text], applicationActivities: nil); let visible = self.tableView.indexPathsForVisibleRows ?? []; if let firstVisible = visible.first(where:{ (indexPath) -> Bool in return paths.contains(indexPath); }) ?? visible.first { activityController.popoverPresentationController?.sourceRect = self.tableView.rectForRow(at: firstVisible); activityController.popoverPresentationController?.sourceView = self.tableView.cellForRow(at: firstVisible); self.navigationController?.present(activityController, animated: true, completion: nil); } } } @objc func showEditToolbar(_ notification: Notification) { guard let cell = notification.object as? UITableViewCell else { return; } DispatchQueue.main.async { self.view.endEditing(true); DispatchQueue.main.async { let selected = self.tableView?.indexPath(for: cell); UIView.animate(withDuration: 0.3) { self.tableView?.isEditing = true; DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.3) { self.tableView?.selectRow(at: selected, animated: false, scrollPosition: .none); } self.tempRightBarButtonItem = self.conversationLogDelegate?.navigationItem.rightBarButtonItem; self.conversationLogDelegate?.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(ConversationLogController.editCancelClicked)); let timestampsSwitch = TimestampsBarButtonItem(); self.conversationLogDelegate?.navigationController?.toolbar.tintColor = UIColor(named: "tintColor"); let items = [ timestampsSwitch, UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(ConversationLogController.shareSelectedMessages)) // UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) ]; self.conversationLogDelegate?.navigationController?.setToolbarHidden(false, animated: true); self.conversationLogDelegate?.setToolbarItems(items, animated: true); } } } } func hideEditToolbar() { UIView.animate(withDuration: 0.3) { self.conversationLogDelegate?.navigationController?.setToolbarHidden(true, animated: true); self.conversationLogDelegate?.setToolbarItems(nil, animated: true); if let temp = self.tempRightBarButtonItem { self.conversationLogDelegate?.navigationItem.rightBarButtonItem = temp; } self.tempRightBarButtonItem = nil; self.tableView?.isEditing = false; } } func getTextOfSelectedRows(paths: [IndexPath], withTimestamps: Bool, handler: (([String]) -> Void)?) { let items: [ConversationEntry] = paths.map({ index in dataSource.getItem(at: index.row)! }).sorted { (it1, it2) -> Bool in it1.timestamp.compare(it2.timestamp) == .orderedAscending; }; let withoutPrefix = Set(items.map({it in it.state.direction})).count == 1; let formatter = DateFormatter(); formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "dd.MM.yyyy jj:mm", options: 0, locale: NSLocale.current); let texts = items.compactMap({ (it) -> String? in switch it.payload { case .message(let message, _): let prefix = withoutPrefix ? "" : "\(it.sender.nickname ?? "") "; if withTimestamps { return "\(formatter.string(from: it.timestamp)) \(prefix)\(message)" } else { return "\(prefix)\(message)" } case .location(let location): let prefix = withoutPrefix ? "" : "\(it.sender.nickname ?? "") "; if withTimestamps { return "\(formatter.string(from: it.timestamp)) \(prefix)\(location.geoUri)" } else { return "\(prefix)\(location.geoUri)" } default: return nil; } }); handler?(texts); } class TimestampsBarButtonItem: UIBarButtonItem { var value: Bool { get { Settings.copyMessagesWithTimestamps; } set { Settings.copyMessagesWithTimestamps = newValue; updateTimestampSwitch(); } } override init() { super.init(); self.style = .plain; self.target = self; self.action = #selector(switchWithTimestamps) self.updateTimestampSwitch(); } required init?(coder: NSCoder) { return nil; } @objc private func switchWithTimestamps() { value = !value; } private func updateTimestampSwitch() { image = UIImage(systemName: value ? "clock.fill" : "clock"); title = nil; } } } protocol ConversationLogDelegate: AnyObject { var navigationItem: UINavigationItem { get } var navigationController: UINavigationController? { get } func initialize(tableView: UITableView); func setToolbarItems(_ toolbarItems: [UIBarButtonItem]?, animated: Bool); } ================================================ FILE: SiskinIM/conversation/InvitationChatTableViewCell.swift ================================================ // // InvitationChatTableViewCell.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class InvitationChatTableViewCell: BaseChatTableViewCell { @IBOutlet var messageField: UILabel!; @IBOutlet var acceptButton: UIButton!; @IBOutlet var defBottomButtonConstraint: NSLayoutConstraint?; private var account: BareJID?; private var appendix: ChatInvitationAppendix?; private var buttonBottomContraint: NSLayoutConstraint?; func set(item: ConversationEntry, message: String?, appendix: ChatInvitationAppendix) { super.set(item: item); self.account = item.conversation.account; self.appendix = appendix; acceptButton.layer.borderWidth = 2.0; acceptButton.layer.borderColor = UIColor(named: "tintColor")?.cgColor; acceptButton.layer.cornerRadius = acceptButton.frame.height / 2; if item.state.direction == .incoming, let account = self.account, let channel = self.appendix?.channel { acceptButton.isHidden = DBChatStore.instance.conversation(for: account, with: channel) != nil; } else { acceptButton.isHidden = true; } if acceptButton.isHidden { if buttonBottomContraint == nil { buttonBottomContraint = self.stateView!.bottomAnchor.constraint(equalTo: self.messageField.bottomAnchor); } buttonBottomContraint?.priority = .required; defBottomButtonConstraint?.isActive = false; buttonBottomContraint?.isActive = true; } else { buttonBottomContraint?.isActive = false; defBottomButtonConstraint?.isActive = true; } self.messageField.text = message ?? String.localizedStringWithFormat(NSLocalizedString("Invitation to channel %@", comment: "conversation log invitation to channel label"), appendix.channel.stringValue); } var viewController: UIViewController? { var parent: UIResponder? = self; while parent != nil { parent = parent?.next; if let controller = parent as? UIViewController { return controller; } } return nil; } @IBAction func acceptClicked(_ sender: UIButton) { guard let account = self.account, let mixInvitation = appendix?.mixInvitation() else { return; } let controller = UIStoryboard(name: "MIX", bundle: nil).instantiateViewController(withIdentifier: "ChannelJoinViewController") as! ChannelJoinViewController; controller.client = XmppService.instance.getClient(for: account); controller.componentType = .mix controller.mixInvitation = mixInvitation; controller.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: controller, action: #selector(ChannelJoinViewController.cancelClicked(_:))); let navitation = UINavigationController(rootViewController: controller); navitation.modalPresentationStyle = .formSheet; viewController?.present(navitation, animated: true, completion: nil); } } ================================================ FILE: SiskinIM/conversation/LinkPreviewChatTableViewCell.swift ================================================ // // LinkPreviewChatTableViewCell.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import LinkPresentation class LinkPreviewChatTableViewCell: BaseChatTableViewCell { private var url: URL?; var linkView: LPLinkView? { didSet { if let value = oldValue { value.metadata = LPLinkMetadata(); value.removeFromSuperview(); } if let value = linkView { self.contentView.addSubview(value); NSLayoutConstraint.activate([ value.topAnchor.constraint(equalTo: self.contentView.topAnchor, constant: 2), value.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, constant: -4), value.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor, constant: 44), value.trailingAnchor.constraint(lessThanOrEqualTo: self.contentView.trailingAnchor, constant: -22) ]); } } } override func prepareForReuse() { self.url = nil; self.linkView?.metadata = LPLinkMetadata(); super.prepareForReuse(); } func set(item: ConversationEntry, url inUrl: String) { super.set(item: item); self.contentView.setContentCompressionResistancePriority(.required, for: .vertical); self.contentView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal); let url = URL(string: inUrl)!; self.url = url; guard let metadata = MetadataCache.instance.metadata(for: "\(item.id)") else { setup(linkView: LPLinkView(metadata: createMetadata(url: url))); MetadataCache.instance.generateMetadata(for: url, withId: "\(item.id)", completionHandler: { [weak self] meta in guard meta != nil else { return; } DispatchQueue.main.async { guard let that = self, that.url == url else { return; } NotificationCenter.default.post(name: ConversationLogController.REFRESH_CELL, object: that); } }) return; } setup(linkView: LPLinkView(metadata: metadata)); } private func createMetadata(url: URL) -> LPLinkMetadata { let metadata = LPLinkMetadata(); metadata.originalURL = url; return metadata; } private func setup(linkView: LPLinkView) { linkView.setContentCompressionResistancePriority(.required, for: .vertical); linkView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal); linkView.translatesAutoresizingMaskIntoConstraints = false; self.linkView = linkView; } } ================================================ FILE: SiskinIM/conversation/LocationChatTableViewCell.swift ================================================ // // LocationChatTableViewCell.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import UIKit import MapKit import CoreLocation class LocationChatTableViewCell: BaseChatTableViewCell { @IBOutlet var mapView: MKMapView! { didSet { let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(mapTapped(_:))); gestureRecognizer.numberOfTapsRequired = 2; mapView.addGestureRecognizer(gestureRecognizer); } } private let annotation = MKPointAnnotation(); func set(item: ConversationEntry, location: CLLocationCoordinate2D) { super.set(item: item); mapView.layer.cornerRadius = 5; mapView.removeAnnotation(annotation); annotation.coordinate = location; mapView.addAnnotation(annotation); mapView.setRegion(MKCoordinateRegion(center: location, latitudinalMeters: 2000, longitudinalMeters: 2000), animated: true); } @objc func mapTapped(_ sender: Any) { let placemark = MKPlacemark(coordinate: annotation.coordinate); let region = MKCoordinateRegion(center: annotation.coordinate, latitudinalMeters: 2000, longitudinalMeters: 2000); let item = MKMapItem(placemark: placemark); item.openInMaps(launchOptions: [ MKLaunchOptionsMapCenterKey: NSValue(mkCoordinate: region.center), MKLaunchOptionsMapSpanKey: NSValue(mkCoordinateSpan: region.span) ]) } } ================================================ FILE: SiskinIM/database/DBCapabilitiesCache.swift ================================================ // // DBCapabilitiesCache.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import TigaseSQLite3 extension Query { static let capsFindFeaturesForNode = Query("SELECT feature FROM caps_features WHERE node = :node"); static let capsFindIdentityForNode = Query("SELECT name, category, type FROM caps_identities WHERE node = :node"); static let capsFindNodesWithFeature = Query("SELECT node FROM caps_features WHERE feature = :feature"); static let capsInsertFeatureForNode = Query("INSERT INTO caps_features (node, feature) VALUES (:node, :feature)"); static let capsInsertIdentityForNode = Query("INSERT INTO caps_identities (node, name, category, type) VALUES (:node, :name, :category, :type)"); static let capsCountFeaturesForNode = Query("SELECT count(feature) FROM caps_features WHERE node = :node"); } class DBCapabilitiesCache: CapabilitiesCache { public static let instance = DBCapabilitiesCache(); public let dispatcher: QueueDispatcher; private var features = [String: [String]](); private var identities: [String: DiscoveryModule.Identity] = [:]; fileprivate init() { dispatcher = QueueDispatcher(label: "DBCapabilitiesCache", attributes: .concurrent); } open func getFeatures(for node: String) -> [String]? { return dispatcher.sync { guard let features = self.features[node] else { let features = try! Database.main.reader({ database in try database.select(query: .capsFindFeaturesForNode, params: ["node": node]).mapAll({ $0.string(for: "feature")}); }) guard !features.isEmpty else { return nil; } self.features[node] = features; return features; } return features; } } open func getIdentity(for node: String) -> DiscoveryModule.Identity? { guard let identity = self.identities[node] else { if let identity = try! Database.main.reader({ database in try database.select(query: .capsFindIdentityForNode, params: ["node": node]).mapFirst({ cursor -> DiscoveryModule.Identity? in guard let category = cursor.string(for: "category"), let type = cursor.string(for: "type") else { return nil; } return DiscoveryModule.Identity(category: category, type: type, name: cursor.string(for: "name")); }); }) { self.identities[node] = identity; return identity; } else { return nil; } } return identity; } open func getNodes(withFeature feature: String) -> [String] { return try! Database.main.reader({ database in try database.select(query: .capsFindNodesWithFeature, params: ["feature": feature]).mapAll({ $0.string(for: "node") }); }) } open func isCached(node: String, handler: @escaping (Bool)->Void) { dispatcher.async { handler(self.isCached(node: node)); } } open func isSupported(for node: String, feature: String) -> Bool { return getFeatures(for: node)?.contains(feature) ?? false; } open func store(node: String, identity: DiscoveryModule.Identity?, features: [String]) { dispatcher.async(flags: .barrier) { guard !self.isCached(node: node) else { return; } self.features[node] = features; self.identities[node] = identity; try! Database.main.writer({ database in for feature in features { try database.insert(query: .capsInsertFeatureForNode, params: ["node": node, "feature": feature]); } if let identity = identity { try database.insert(query: .capsInsertIdentityForNode, params: ["node": node, "name": identity.name, "category": identity.category, "type": identity.type]); } }) } } fileprivate func isCached(node: String) -> Bool { do { return try Database.main.reader({ database in try database.count(query: .capsCountFeaturesForNode, params: ["node": node]); }) > 0 } catch { // it is better to assume that we have features... return true; } } } ================================================ FILE: SiskinIM/database/DBChatHistoryStore.swift ================================================ // // DBChatHistoryStore.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import TigaseSQLite3 import TigaseLogging import Combine import CoreLocation extension Query { static let messagesLastTimestampForAccount = Query("SELECT max(ch.timestamp) as timestamp FROM chat_history ch WHERE ch.account = :account AND ch.state <> \(ConversationEntryState.outgoing(.unsent).rawValue)"); static let messageInsert = Query("INSERT INTO chat_history (account, jid, timestamp, item_type, data, stanza_id, state, author_nickname, author_jid, recipient_nickname, participant_id, error, encryption, fingerprint, appendix, server_msg_id, remote_msg_id, master_id, markable) VALUES (:account, :jid, :timestamp, :item_type, :data, :stanza_id, :state, :author_nickname, :author_jid, :recipient_nickname, :participant_id, :error, :encryption, :fingerprint, :appendix, :server_msg_id, :remote_msg_id, :master_id, :markable)"); // if server has MAM:2 then use server_msg_id for checking // if there is no result, try to match using origin-id/stanza-id (if there is one in a form of UUID) and update server_msg_id if message is found // if there is was no origin-id/stanza-id then use old check with timestamp range and all of that.. static let messageFindIdByServerMsgId = Query("SELECT id FROM chat_history WHERE account = :account AND server_msg_id = :server_msg_id"); static let messageFindIdByRemoteMsgId = Query("SELECT id FROM chat_history WHERE account = :account AND jid = :jid AND remote_msg_id = :remote_msg_id"); static let messageFindIdByOriginId = Query("SELECT id, timestamp FROM chat_history indexed by chat_history_account_jid_stanza_id WHERE account = :account AND jid = :jid AND stanza_id = :stanza_id AND (:author_nickname IS NULL OR author_nickname = :author_nickname) AND (:participant_id IS NULL OR participant_id = :participant_id) UNION ALL SELECT id, timestamp FROM chat_history indexed by chat_history_account_jid_correction_stanza_id WHERE account = :account AND jid = :jid AND correction_stanza_id = :stanza_id AND (:author_nickname IS NULL OR author_nickname = :author_nickname) AND (:participant_id IS NULL OR participant_id = :participant_id) ORDER BY timestamp DESC"); static let messageUpdateServerMsgId = Query("UPDATE chat_history SET server_msg_id = :server_msg_id WHERE id = :id AND server_msg_id is null"); static let messageUpdateRemoteMsgId = Query("UPDATE chat_history SET remote_msg_id = :remote_msg_id WHERE id = :id AND remote_msg_id is null"); static let messageFindLinkPreviewsForMessage = Query("SELECT id, account, jid, data FROM chat_history WHERE master_id = :master_id AND item_type = \(ItemType.linkPreview.rawValue)"); static let messageDelete = Query("DELETE FROM chat_history WHERE id = :id"); static let messageFindMessageOriginId = Query("select stanza_id from chat_history where id = :id"); static let messageFindStableIds = Query("SELECT server_msg_id, remote_msg_id FROM chat_history WHERE id = :id"); static let messagesFindUnsent = Query("SELECT ch.account as account, ch.jid as jid, ch.item_type as item_type, ch.data as data, ch.stanza_id as stanza_id, ch.encryption as encryption, ch.markable FROM chat_history ch WHERE ch.account = :account AND ch.state = \(ConversationEntryState.outgoing(.unsent).rawValue) ORDER BY timestamp ASC"); static let messagesFindForChat = Query("SELECT id, author_nickname, author_jid, recipient_nickname, participant_id, timestamp, item_type, data, state, preview, encryption, fingerprint, error, appendix, correction_timestamp, markable FROM chat_history WHERE account = :account AND jid = :jid AND (:showLinkPreviews OR item_type IN (\(ItemType.message.rawValue), \(ItemType.messageRetracted.rawValue), \(ItemType.attachment.rawValue), \(ItemType.invitation.rawValue), \(ItemType.location.rawValue))) ORDER BY timestamp DESC LIMIT :limit OFFSET :offset"); static let messageFindPositionInChat = Query("SELECT count(id) FROM chat_history WHERE account = :account AND jid = :jid AND id <> :msgId AND (:showLinkPreviews OR item_type IN (\(ItemType.message.rawValue), \(ItemType.attachment.rawValue), \(ItemType.invitation.rawValue), \(ItemType.location.rawValue))) AND timestamp > (SELECT timestamp FROM chat_history WHERE id = :msgId)"); static let messageSearchHistory = Query("SELECT chat_history.id as id, chat_history.account as account, chat_history.jid as jid, author_nickname, author_jid, participant_id, chat_history.timestamp as timestamp, item_type, chat_history.data as data, state, preview, chat_history.encryption as encryption, fingerprint, markable FROM chat_history INNER JOIN chat_history_fts_index ON chat_history.id = chat_history_fts_index.rowid LEFT JOIN chats ON chats.account = chat_history.account AND chats.jid = chat_history.jid WHERE (chats.id IS NOT NULL OR chat_history.author_nickname is NULL) AND chat_history_fts_index MATCH :query AND (:account IS NULL OR chat_history.account = :account) AND (:jid IS NULL OR chat_history.jid = :jid) AND item_type = \(ItemType.message.rawValue) ORDER BY chat_history.timestamp DESC"); static let messagesDeleteChatHistory = Query("DELETE FROM chat_history WHERE account = :account AND (:jid IS NULL OR jid = :jid)"); static let messagesFindChatAttachments = Query("SELECT id, author_nickname, author_jid, recipient_nickname, participant_id, timestamp, item_type, data, state, preview, encryption, fingerprint, error, appendix, correction_timestamp, markable FROM chat_history WHERE account = :account AND jid = :jid AND item_type = \(ItemType.attachment.rawValue) ORDER BY timestamp DESC"); private static let messageRetractionStateMapping: [ConversationEntryState: ConversationEntryState] = [ .incoming_error(.received, errorMessage: nil) : .incoming_error(.displayed, errorMessage: nil), .incoming(.received) : .incoming(.displayed), .outgoing_error(.received, errorMessage: nil) : .outgoing_error(.displayed, errorMessage: nil) ]; private static func prepareMessageRetractStateCase() -> String { return messageRetractionStateMapping.map({ (k,v) -> String in return "when \(k.rawValue) then \(v.rawValue)"; }).joined(separator: " "); } static let messageRetract = Query("UPDATE chat_history SET state = case state \(prepareMessageRetractStateCase()) else state end, item_type = :item_type, correction_stanza_id = :correction_stanza_id, correction_timestamp = :correction_timestamp, remote_msg_id = :remote_msg_id, server_msg_id = COALESCE(:server_msg_id, server_msg_id) WHERE id = :id AND (correction_stanza_id IS NULL OR correction_stanza_id <> :correction_stanza_id) AND (correction_timestamp IS NULL OR correction_timestamp < :correction_timestamp)") static let messageCorrectLast = Query("UPDATE chat_history SET data = :data, state = :state, correction_stanza_id = :correction_stanza_id, correction_timestamp = :correction_timestamp, remote_msg_id = :remote_msg_id, server_msg_id = COALESCE(:server_msg_id, server_msg_id) WHERE id = :id AND (correction_stanza_id IS NULL OR correction_stanza_id <> :correction_stanza_id) AND (correction_timestamp IS NULL OR correction_timestamp < :correction_timestamp)"); static let messageFind = Query("SELECT id, account, jid, author_nickname, author_jid, recipient_nickname, participant_id, timestamp, item_type, data, state, preview, encryption, fingerprint, error, appendix, correction_stanza_id, correction_timestamp, markable FROM chat_history WHERE id = :id"); static let messagesUnreadBefore = Query("SELECT id, case when (recipient_nickname is null and (markable = 1 or author_nickname is not null)) then ifnull(remote_msg_id, stanza_id) else null end markable_id FROM chat_history indexed by chat_history_account_jid_state_idx WHERE account = :account AND jid = :jid AND timestamp <= :before AND state in (\(ConversationEntryState.incoming(.received).rawValue), \(ConversationEntryState.incoming_error(.received, errorMessage: nil).rawValue), \(ConversationEntryState.outgoing_error(.received, errorMessage: nil).rawValue)) order by timestamp asc"); static let messagesMarkAsReadBefore = Query("UPDATE chat_history indexed by chat_history_account_jid_state_idx SET state = case state when \(ConversationEntryState.incoming_error(.received).rawValue) then \(ConversationEntryState.incoming_error(.displayed).rawValue) when \(ConversationEntryState.outgoing_error(.received).rawValue) then \(ConversationEntryState.outgoing_error(.displayed).rawValue) else \(ConversationEntryState.incoming(.displayed).rawValue) end WHERE account = :account AND jid = :jid AND timestamp <= :before AND state in (\(ConversationEntryState.incoming(.received).rawValue), \(ConversationEntryState.incoming_error(.received).rawValue), \(ConversationEntryState.outgoing_error(.received).rawValue))"); static let messageUpdateState = Query("UPDATE chat_history SET state = :newState, timestamp = COALESCE(:newTimestamp, timestamp), error = COALESCE(:error, error) WHERE id = :id AND (:oldState IS NULL OR state = :oldState)"); static let messageUpdate = Query("UPDATE chat_history SET appendix = :appendix WHERE id = :id"); static let messagesCountUnread = Query("select count(id) from chat_history where account = :account and jid = :jid and timestamp >= (select min(timestamp) from chat_history indexed by chat_history_account_jid_state_idx where account = :account and jid = :jid and state in (\(ConversationEntryState.incoming(.received).rawValue),\(ConversationEntryState.incoming_error(.received).rawValue),\(ConversationEntryState.outgoing_error(.received).rawValue)))"); static let messagesCountUnsent = Query("SELECT count(id) FROM chat_history WHERE state = \(ConversationEntryState.outgoing(.unsent).rawValue)"); } class DBChatHistoryStore { static let MESSAGE_NEW = Notification.Name("messageAdded"); static let MESSAGE_UPDATED = Notification.Name("messageUpdated"); static let MESSAGE_REMOVED = Notification.Name("messageRemoved"); static var instance: DBChatHistoryStore = DBChatHistoryStore.init(); static func convertToAttachments() { let diskCacheUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent(Bundle.main.bundleIdentifier!).appendingPathComponent("download", isDirectory: true); guard FileManager.default.fileExists(atPath: diskCacheUrl.path) else { return; } let previewsToConvert: [Int] = try! Database.main.reader({ database in try database.select("SELECT id FROM chat_history WHERE preview IS NOT NULL", cached: false).mapAll({ $0.int(for: "id") }); }) let removePreview = { (id: Int) in try! Database.main.writer({ database in try database.update("UPDATE chat_history SET preview = NULL WHERE id = ?", params: [id]); }) }; for id in previewsToConvert { guard let (item, previews, stanzaId) = try! Database.main.reader({ database in return try database.select("SELECT id, account, jid, author_nickname, author_jid, timestamp, item_type, data, state, preview, encryption, fingerprint, error, appendix, preview, stanza_id, correction_timestamp, markable FROM chat_history WHERE id = ?", cached: true, params: [id]).mapFirst({ cursor -> (ConversationEntry, [String:String], String?)? in let account: BareJID = cursor["account"]!; let jid: BareJID = cursor["jid"]!; let key = ConversationKeyItem(account: account, jid: jid); let stanzaId: String? = cursor["stanza_id"]; guard let item = DBChatHistoryStore.instance.itemFrom(cursor: cursor, for: key), let previewStr: String = cursor["preview"] else { return nil; } var previews: [String:String] = [:]; previewStr.split(separator: "\n").forEach { (line) in let tmp = line.split(separator: "\t").map({String($0)}); if (!tmp[1].starts(with: "ERROR")) && (tmp[1] != "NONE") { previews[tmp[0]] = tmp[1]; } } return (item, previews, stanzaId); }); }) else { return; } if previews.isEmpty { removePreview(item.id); } else { if previews.count == 1 { switch item.payload { case .message(let message, _): let isAttachmentOnly = URL(string: message) != nil; if isAttachmentOnly { let appendix = ChatAttachmentAppendix(); DBChatHistoryStore.instance.appendItem(for: item.conversation, state: item.state, sender: item.sender, type: .attachment, timestamp: item.timestamp, stanzaId: stanzaId, serverMsgId: nil, remoteMsgId: nil, data: message, appendix: appendix, options: item.options, linkPreviewAction: .none, masterId: nil, completionHandler: { newId in DBChatHistoryStore.instance.remove(item: item); }); } else { DBChatHistoryStore.instance.appendItem(for: item.conversation, state: item.state, sender: item.sender, type: .linkPreview, timestamp: item.timestamp, stanzaId: stanzaId, serverMsgId: nil, remoteMsgId: nil, data: previews.keys.first ?? message, options: item.options, linkPreviewAction: .none, masterId: nil, completionHandler: { newId in removePreview(item.id); }); } default: break; } } else { let group = DispatchGroup(); group.enter(); group.notify(queue: DispatchQueue.main, execute: { removePreview(item.id); }) for (url, _) in previews { group.enter(); DBChatHistoryStore.instance.appendItem(for: item.conversation, state: item.state, sender: item.sender, type: .linkPreview, timestamp: item.timestamp, stanzaId: stanzaId, serverMsgId: nil, remoteMsgId: nil, data: url, options: item.options, linkPreviewAction: .none, masterId: nil, completionHandler: { newId in group.leave(); }); } group.leave(); } } } try? FileManager.default.removeItem(at: diskCacheUrl); } public enum MessageEvent { case added(ConversationEntry) case updated(ConversationEntry) case removed(ConversationEntry) } public let events = PassthroughSubject(); private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DBChatHistoryStore"); public init() { previewGenerationQueue.maxConcurrentOperationCount = 1; } open func process(chatState: ChatState, for conversation: ConversationKey) { self.process(chatState: chatState, for: conversation.account, with: conversation.jid); } open func process(chatState: ChatState, for account: BareJID, with jid: BareJID) { DBChatStore.instance.process(chatState: chatState, for: account, with: jid); } enum MessageSource { case stream case archive(source: BareJID, version: MessageArchiveManagementModule.Version, messageId: String, timestamp: Date) case carbons(action: MessageCarbonsModule.Action) } private var enqueuedItems = 0; open func append(for conversation: ConversationKey, message: Message, source: MessageSource) { let direction: MessageDirection = conversation.account == message.from?.bareJid ? .outgoing : .incoming; guard let jidFull = direction == .outgoing ? message.to : message.from else { // sender jid should always be there.. return; } let jid = jidFull.withoutResource; let mixInvitation = message.mixInvitation; let stanzaId = message.originId ?? message.id; var stableIds = message.stanzaId; var fromArchive = false; var inTimestamp: Date?; switch source { case .archive(let source, let version, let messageId, let timestamp): if version == .MAM2 { if stableIds == nil { stableIds = [source: messageId]; } else { stableIds?[source] = messageId; } } inTimestamp = timestamp; if message.type == .groupchat { fromArchive = false; //source != account; } else { // we should not mark messages only from MAM as READ unless they are marked with chat marker fromArchive = false; } default: inTimestamp = message.delay?.stamp; break; } let serverMsgId: String? = stableIds?[conversation.account]; let remoteMsgId: String? = stableIds?[jid.bareJid]; guard let (sender, recipient) = MessageEventHandler.extractRealAuthor(from: message, for: conversation) else { return; } let state = MessageEventHandler.calculateState(direction: MessageEventHandler.calculateDirection(for: conversation, direction: direction, sender: sender), message: message, isFromArchive: fromArchive, isMuc: message.type == .groupchat && message.mix == nil); let timestamp = Date(timeIntervalSince1970: Double(Int64((inTimestamp ?? Date()).timeIntervalSince1970 * 1000)) / 1000); if let stableId = serverMsgId, self.findItemId(for: conversation.account, serverMsgId: stableId) != nil { return; } if let originId = stanzaId, message.type == .groupchat || direction == .outgoing, let existingMessageId = self.findItemId(for: conversation, originId: originId, sender: sender) { if let stableId = serverMsgId { try! Database.main.writer({ database in try database.update(query: .messageUpdateServerMsgId, params: ["id": existingMessageId, "server_msg_id": stableId]); }) } if let remoteId = remoteMsgId { try! Database.main.writer({ database in try database.update(query: .messageUpdateRemoteMsgId, params: ["id": existingMessageId, "remote_msg_id": remoteId]); }) } return; } guard !state.isError || stanzaId == nil || !self.processOutgoingError(for: conversation, stanzaId: stanzaId!, errorCondition: message.errorCondition, errorMessage: message.errorText) else { return; } if let retractedId = message.messageRetractionId, let originId = stanzaId { self.retractMessageSync(for: conversation, stanzaId: retractedId, sender: sender, retractionStanzaId: originId, retractionTimestamp: timestamp, serverMsgId: serverMsgId, remoteMsgId: remoteMsgId); return; } if message.type == .groupchat, let moderation = message.moderated { switch moderation.retraction { case .retract(let stanzaIdToRetract): guard let oldItemId = findItemId(for: conversation, remoteMsgId: stanzaIdToRetract), let oldItem = self.message(for: conversation, withId: oldItemId) else { return; } retractMessageSync(oldItem: oldItem, for: conversation, retractionStanzaId: stanzaId, retractionTimestamp: timestamp, serverMsgId: serverMsgId, remoteMsgId: remoteMsgId); case .retracted(let retractionTimestamp): self.appendItemSync(for: conversation, state: state, sender: sender, type: .messageRetracted, timestamp: timestamp, stanzaId: stanzaId, serverMsgId: serverMsgId, remoteMsgId: remoteMsgId, data: "", chatState: nil, appendix: ChatAttachmentAppendix(), options: .init(recipient: recipient, encryption: .none, isMarkable: true), linkPreviewAction: .none, completionHandler: nil); } return; } let (decryptedBody, encryption) = MessageEventHandler.prepareBody(message: message, forAccount: conversation.account, serverMsgId: serverMsgId); guard let body = decryptedBody ?? (mixInvitation != nil ? "Invitation" : nil) else { switch source { case .carbons(let action): if action == .received { if (message.type ?? .normal) != .error, let chatState = message.chatState, message.delay == nil { DBChatHistoryStore.instance.process(chatState: chatState, for: conversation); } } default: if (message.type ?? .normal) != .error, let chatState = message.chatState, message.delay == nil { DBChatHistoryStore.instance.process(chatState: chatState, for: conversation); } break; } return; } var appendix: AppendixProtocol? = nil; var itemType = MessageEventHandler.itemType(fromMessage: message); if itemType == .message, let mixInivation = mixInvitation { itemType = .invitation; appendix = ChatInvitationAppendix(mixInvitation: mixInivation); } if let originId = stanzaId, let correctedMessageId = message.lastMessageCorrectionId, self.correctMessageSync(for: conversation, stanzaId: correctedMessageId, sender: sender, data: body, correctionStanzaId: originId, correctionTimestamp: timestamp, serverMsgId: serverMsgId, remoteMsgId: remoteMsgId, newState: state) { if let chatState = message.chatState { DBChatStore.instance.process(chatState: chatState, for: conversation.account, with: conversation.jid); } return; } let options = ConversationEntry.Options(recipient: recipient, encryption: encryption, isMarkable: message.isMarkable) self.appendItemSync(for: conversation, state: state, sender: sender, type: itemType, timestamp: timestamp, stanzaId: stanzaId, serverMsgId: serverMsgId, remoteMsgId: remoteMsgId, data: body, chatState: message.chatState, appendix: appendix, options: options, linkPreviewAction: .auto, masterId: nil, completionHandler: nil); if state.direction == .outgoing { self.markAsRead(for: conversation.account, with: conversation.jid, before: timestamp, sendMarkers: false); } else { if recipient == .none { switch sender { case .none, .me(_): break case .buddy(_): if let originId = stanzaId { var receipts: [MessageEventHandler.ReceiptType] = options.isMarkable ? [.chatMarker] : []; if let receipt = message.messageDelivery, case .request = receipt { receipts.append(.deliveryReceipt); } MessageEventHandler.instance.sendReceived(for: conversation, timestamp: timestamp, stanzaId: originId, receipts: receipts); } case .occupant(_,_), .participant(_, _, _), .channel: if let stanzaId = remoteMsgId { let receipts: [MessageEventHandler.ReceiptType] = options.isMarkable ? [.chatMarker] : []; MessageEventHandler.instance.sendReceived(for: conversation, timestamp: timestamp, stanzaId: stanzaId, receipts: receipts); } } } } } enum LinkPreviewAction { case auto case none case only } func findItemId(for conversation: ConversationKey, remoteMsgId: String) -> Int? { return try! Database.main.reader({ database -> Int? in return try database.select(query: .messageFindIdByRemoteMsgId, params: ["remote_msg_id": remoteMsgId, "account": conversation.account, "jid": conversation.jid]).mapFirst({ $0.int(for: "id") }); }) } private func findItemId(for account: BareJID, serverMsgId: String) -> Int? { return try! Database.main.reader({ database -> Int? in return try database.select(query: .messageFindIdByServerMsgId, params: ["server_msg_id": serverMsgId, "account": account]).mapFirst({ $0.int(for: "id") }); }) } func findItemId(for conversation: ConversationKey, originId: String, sender: ConversationEntrySender) -> Int? { var params: [String: Any?] = ["stanza_id": originId, "account": conversation.account, "jid": conversation.jid, "author_nickname": nil, "participant_id": nil]; switch sender { case .none, .buddy(_), .me(_), .channel: break; case .occupant(let nickname, _): params["author_nickname"] = nickname; case .participant(let id, _, _): params["participant_id"] = id; } return try! Database.main.reader({ database -> Int? in return try database.select(query: .messageFindIdByOriginId, params: params).mapFirst({ $0.int(for: "id") }); }) } // private func findItemId(for account: BareJID, with jid: BareJID, timestamp: Date, direction: MessageDirection, itemType: ItemType, stanzaId: String?, authorNickname: String?, data: String?) -> Int? { // let range = stanzaId == nil ? 5.0 : 60.0; // let ts_from = timestamp.addingTimeInterval(-60 * range); // let ts_to = timestamp.addingTimeInterval(60 * range); // // let params: [String: Any?] = ["account": account, "jid": jid, "ts_from": ts_from, "ts_to": ts_to, "item_type": itemType.rawValue, "direction": direction.rawValue, "stanza_id": stanzaId, "data": data, "author_nickname": authorNickname]; // // return try! self.findItemFallback.findFirst(params, map: { cursor -> Int? in // return cursor["id"]; // }) // } private func appendItemSync(for conversation: ConversationKey, state: ConversationEntryState, sender: ConversationEntrySender, type inType: ItemType, timestamp: Date, stanzaId: String?, serverMsgId: String?, remoteMsgId: String?, data: String, chatState: ChatState?, appendix: AppendixProtocol?, options: ConversationEntry.Options, linkPreviewAction: LinkPreviewAction, masterId: Int? = nil, completionHandler: ((Int) -> Void)?) { var item: ConversationEntry?; var type = inType; if linkPreviewAction != .only { var payload: ConversationEntryPayload?; switch type { case .message: if let location = CLLocationCoordinate2D(geoUri: data) { payload = .location(location: location); type = .location; } else { payload = .message(message: data, correctionTimestamp: nil); } case .location: payload = .location(location: CLLocationCoordinate2D(geoUri: data)!); case .invitation: payload = .invitation(message: data, appendix: appendix as! ChatInvitationAppendix); case .attachment: payload = .attachment(url: data, appendix: (appendix as? ChatAttachmentAppendix) ?? ChatAttachmentAppendix()); case .linkPreview: if Settings.linkPreviews { payload = .linkPreview(url: data) } case .messageRetracted, .attachmentRetracted: // nothing to do, as we do not want notifications for that (at least for now and no item of that type would be created in here! break; } var params: [String:Any?] = ["account": conversation.account, "jid": conversation.jid, "timestamp": timestamp, "data": data, "item_type": type.rawValue, "state": state.code, "stanza_id": stanzaId, "author_nickname": nil, "author_jid": nil, "recipient_nickname": options.recipient.nickname, "participant_id": nil, "encryption": options.encryption.value.rawValue, "fingerprint": options.encryption.fingerprint ?? (options.encryption.errorCode != nil ? "\(options.encryption.errorCode!)" : nil), "error": state.errorMessage, "appendix": appendix, "server_msg_id": serverMsgId, "remote_msg_id": remoteMsgId, "master_id": masterId, "markable": options.isMarkable]; switch sender { case .none, .me(_), .buddy(_), .channel: break; case .occupant(let nickname, let jid): params["author_nickname"] = nickname; params["author_jid"] = jid; case .participant(let id, let nickname, let jid): params["participant_id"] = id; params["author_nickname"] = nickname; params["author_jid"] = jid; } guard let id = try! Database.main.writer({ database -> Int? in switch sender { case .occupant(_, _): if let originId = stanzaId, let existingMessageId = self.findItemId(for: conversation, originId: originId, sender: sender) { if let stableId = serverMsgId { try database.update(query: .messageUpdateServerMsgId, params: ["id": existingMessageId, "server_msg_id": stableId]); } return nil; } default: break; } try database.insert(query: .messageInsert, params: params); return database.lastInsertedRowId; }) else { return; } completionHandler?(id); if let payload = payload { let entry = ConversationEntry(id: id, conversation: conversation, timestamp: timestamp, state: state, sender: sender, payload: payload, options: options); DBChatStore.instance.newMessage(for: conversation.account, with: conversation.jid, timestamp: timestamp, itemType: type, message: options.encryption.message() ?? data, state: state, remoteChatState: state.direction == .incoming ? chatState : nil, senderNickname: sender.isGroupchat ? sender.nickname : nil) { NotificationCenter.default.post(name: DBChatHistoryStore.MESSAGE_NEW, object: entry); } self.events.send(.added(entry)); NotificationManager.instance.newMessage(entry); item = entry; } } if linkPreviewAction != .none && type == .message, let id = item?.id { self.generatePreviews(forItem: id, conversation: conversation, state: state, sender: sender, timestamp: timestamp, data: data, options: options, action: .new); } } open func appendItem(for conversation: ConversationKey, state: ConversationEntryState, sender: ConversationEntrySender, type: ItemType, timestamp inTimestamp: Date, stanzaId: String?, serverMsgId: String?, remoteMsgId: String?, data: String, chatState: ChatState? = nil, appendix: AppendixProtocol? = nil, options: ConversationEntry.Options, linkPreviewAction: LinkPreviewAction, masterId: Int? = nil, completionHandler: ((Int) -> Void)?) { let timestamp = Date(timeIntervalSince1970: Double(Int64(inTimestamp.timeIntervalSince1970 * 1000)) / 1000); self.appendItemSync(for: conversation, state: state, sender: sender, type: type, timestamp: timestamp, stanzaId: stanzaId, serverMsgId: serverMsgId, remoteMsgId: remoteMsgId, data: data, chatState: chatState, appendix: appendix, options: options, linkPreviewAction: linkPreviewAction, masterId: masterId, completionHandler: completionHandler); } open func removeHistory(for account: BareJID, with jid: JID?) { try! Database.main.writer({ database in try database.delete(query: .messagesDeleteChatHistory, cached: false, params: ["account": account, "jid": jid]); }) } open func correctMessage(for conversation: ConversationKey, stanzaId: String, sender: ConversationEntrySender, data: String, correctionStanzaId: String?, correctionTimestamp: Date, newState: ConversationEntryState) { let timestamp = Date(timeIntervalSince1970: Double(Int64((correctionTimestamp).timeIntervalSince1970 * 1000)) / 1000); _ = self.correctMessageSync(for: conversation, stanzaId: stanzaId, sender: sender, data: data, correctionStanzaId: correctionStanzaId, correctionTimestamp: timestamp, serverMsgId: nil, remoteMsgId: nil, newState: newState); } private func correctMessageSync(for conversation: ConversationKey, stanzaId: String, sender: ConversationEntrySender, data: String, correctionStanzaId: String?, correctionTimestamp: Date, serverMsgId: String?, remoteMsgId: String?, newState: ConversationEntryState) -> Bool { // we need to check participant-id/sender nickname to make it work correctly // moreover, stanza-id should be checked with origin-id for MUC/MIX (not message id) // MIX/MUC should send origin-id if they assume to use last message correction! if let oldItem = self.findItem(for: conversation, originId: stanzaId, sender: sender) { let itemId = oldItem.id; let params: [String: Any?] = ["id": itemId, "data": data, "state": newState.code, "correction_stanza_id": correctionStanzaId, "remote_msg_id": remoteMsgId, "server_msg_id": serverMsgId, "correction_timestamp": correctionTimestamp]; let updated = try! Database.main.writer({ database -> Int in try! database.update(query: .messageCorrectLast, params: params); return database.changes; }) if updated > 0 { var markAsReadTimestamp = oldItem.timestamp; if case .message(_, let prevCorrectionTime) = oldItem.payload, let timestamp = prevCorrectionTime { markAsReadTimestamp = timestamp; } markedAsRead.send(MarkedAsRead(account: conversation.account, jid: conversation.jid, messages: [.init(id: oldItem.id, markableId: nil)], before: markAsReadTimestamp.addingTimeInterval(0.1), onlyLocally: true)); let newMessageState: ConversationEntryState = (oldItem.state.direction == .incoming) ? (oldItem.state.isUnread ? .incoming(.displayed) : .incoming(newState.isUnread ? .received : .displayed)) : (.outgoing(.sent)); DBChatStore.instance.newMessage(for: conversation.account, with: conversation.jid, timestamp: oldItem.timestamp, itemType: .message, message: data, state: newMessageState, completionHandler: { }) logger.debug("correcing previews for master id: \(itemId)"); self.itemUpdated(withId: itemId, for: conversation); NotificationManager.instance.newMessage(ConversationEntry(id: itemId, conversation: oldItem.conversation, timestamp: oldItem.timestamp, state: oldItem.state, sender: sender, payload: .message(message: data, correctionTimestamp: correctionTimestamp), options: oldItem.options)); if case .outgoing(let state) = newState, state == .unsent { } else { self.generatePreviews(forItem: itemId, conversation: conversation, state: newState, action: .update); } } return true; } else { if let originId = correctionStanzaId { return findItemId(for: conversation, originId: originId, sender: sender) != nil; } else { return false; } } } public func retractMessage(for conversation: Conversation, stanzaId: String, sender: ConversationEntrySender, retractionStanzaId: String?, retractionTimestamp: Date, serverMsgId: String?, remoteMsgId: String?) { self.retractMessageSync(for: conversation, stanzaId: stanzaId, sender: sender, retractionStanzaId: retractionStanzaId, retractionTimestamp: retractionTimestamp, serverMsgId: serverMsgId, remoteMsgId: remoteMsgId); } private func retractMessageSync(for conversation: ConversationKey, stanzaId: String, sender: ConversationEntrySender, retractionStanzaId: String?, retractionTimestamp: Date, serverMsgId: String?, remoteMsgId: String?) { if let oldItem = self.findItem(for: conversation, originId: stanzaId, sender: sender) { retractMessageSync(oldItem: oldItem, for: conversation, retractionStanzaId: retractionStanzaId, retractionTimestamp: retractionTimestamp, serverMsgId: serverMsgId, remoteMsgId: remoteMsgId); } } private func retractMessageSync(oldItem: ConversationEntry, for conversation: ConversationKey, retractionStanzaId: String?, retractionTimestamp: Date, serverMsgId: String?, remoteMsgId: String?) { let itemId = oldItem.id; var itemType: ItemType = .messageRetracted; if case .attachment(_,_) = oldItem.payload { itemType = .attachmentRetracted; } let params: [String: Any?] = ["id": itemId, "item_type": itemType.rawValue, "correction_stanza_id": retractionStanzaId, "remote_msg_id": remoteMsgId, "server_msg_id": serverMsgId, "correction_timestamp": retractionTimestamp]; let updated = try! Database.main.writer({ database -> Int in try database.update(query: .messageRetract, params: params); return database.changes; }) if updated > 0 { var markAsReadTimestamp = oldItem.timestamp; if case .message(_, let prevCorrectionTime) = oldItem.payload, let timestamp = prevCorrectionTime { markAsReadTimestamp = timestamp; } markedAsRead.send(MarkedAsRead(account: conversation.account, jid: conversation.jid, messages: [.init(id: oldItem.id, markableId: nil)], before: markAsReadTimestamp.addingTimeInterval(0.1), onlyLocally: true)); // what should be sent to "newMessage" how to reatract message from there?? let activity: LastChatActivity = DBChatStore.instance.lastActivity(for: conversation.account, jid: conversation.jid) ?? .message("", direction: .incoming, sender: nil); DBChatStore.instance.newMessage(for: conversation.account, with: conversation.jid, timestamp: oldItem.timestamp, lastActivity: activity, state: oldItem.state.direction == .incoming ? .incoming(.displayed) : .outgoing(.sent), completionHandler: { self.logger.debug("chat store state updated with message retraction for \(itemId)"); }) if oldItem.state.isUnread { DBChatStore.instance.markAsRead(for: conversation.account, with: conversation.jid, count: 1); } self.itemUpdated(withId: itemId, for: conversation); // self.itemRemoved(withId: itemId, for: account, with: jid); self.generatePreviews(forItem: itemId, conversation: conversation, state: oldItem.state, action: .remove); } } private func findItem(for conversation: ConversationKey, originId: String, sender: ConversationEntrySender) -> ConversationEntry? { guard let itemId = findItemId(for: conversation, originId: originId, sender: sender) else { return nil; } return message(for: conversation, withId: itemId); } func message(for conversation: ConversationKey, withId msgId: Int) -> ConversationEntry? { return try! Database.main.writer({ database -> ConversationEntry? in return try database.select(query: .messageFind, params: ["id": msgId]).mapFirst({ cursor -> ConversationEntry? in return self.itemFrom(cursor: cursor, for: conversation); }); }); } private func conversation(withId msgId: Int) -> Conversation? { guard let (account, jid) = try! Database.main.writer({ database -> (BareJID, BareJID)? in return try database.select(query: .messageFind, params: ["id": msgId]).mapFirst({ cursor -> (BareJID,BareJID)? in guard let account = cursor.bareJid(for: "account"), let jid = cursor.bareJid(for: "jid") else { return nil; } return (account, jid); }); }) else { return nil; } return DBChatStore.instance.conversation(for: account, with: jid); } private func generatePreviews(forItem masterId: Int, conversation: ConversationKey, state: ConversationEntryState, action: PreviewActon) { guard let item = self.message(for: conversation, withId: masterId), case .message(let message, _) = item.payload else { return; } self.generatePreviews(forItem: item.id, conversation: conversation, state: item.state, sender: item.sender, timestamp: item.timestamp, data: message, options: item.options, action: action); } private let previewGenerationQueue = OperationQueue();//QueueDispatcher(label: "chat_history_store", attributes: [.concurrent]); private enum PreviewActon { case new case update case remove } private func generatePreviews(forItem masterId: Int, conversation: ConversationKey, state entryState: ConversationEntryState, sender: ConversationEntrySender, timestamp: Date, data: String, options: ConversationEntry.Options, action: PreviewActon) { let operation = BlockOperation(block: { if action != .new { DBChatHistoryStore.instance.removePreviews(idOfRelatedToItem: masterId); } if action != .remove { self.logger.debug("generating previews for master id: \(masterId)"); // if we may have previews, we should add them here.. if let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue | NSTextCheckingResult.CheckingType.address.rawValue) { let matches = detector.matches(in: data, range: NSMakeRange(0, data.utf16.count)); self.logger.debug("adding previews for master id: \(masterId)"); let state = entryState == .incoming(.received) ? .incoming(.displayed) : entryState; let newOptions = ConversationEntry.Options(recipient: options.recipient, encryption: .none, isMarkable: false); for match in matches { if let url = match.url, let scheme = url.scheme, ["https", "http"].contains(scheme) { if (data as NSString).range(of: "http", options: .caseInsensitive, range: match.range).location == match.range.location { DBChatHistoryStore.instance.appendItem(for: conversation, state: state, sender: sender, type: .linkPreview, timestamp: timestamp, stanzaId: nil, serverMsgId: nil, remoteMsgId: nil, data: url.absoluteString, options: newOptions, linkPreviewAction: .none, masterId: masterId, completionHandler: nil); } } if let address = match.components { let query = address.values.joined(separator: ",").addingPercentEncoding(withAllowedCharacters: .urlHostAllowed); let mapUrl = URL(string: "http://maps.apple.com/?q=\(query!)")!; DBChatHistoryStore.instance.appendItem(for: conversation, state: state, sender: sender, type: .linkPreview, timestamp: timestamp, stanzaId: nil, serverMsgId: nil, remoteMsgId: nil, data: mapUrl.absoluteString, options: newOptions, linkPreviewAction: .none, masterId: masterId, completionHandler: nil); } } } } }); previewGenerationQueue.addOperation(operation); } fileprivate func processOutgoingError(for conversation: ConversationKey, stanzaId: String, errorCondition: ErrorCondition?, errorMessage: String?) -> Bool { guard let itemId = findItemId(for: conversation, originId: stanzaId, sender: .none) else { return false; } guard try! Database.main.writer({ database -> Int in try! database.update(query: .messageUpdateState, params: ["id": itemId, "newState": ConversationEntryState.outgoing_error(.received).rawValue, "error": errorMessage ?? errorCondition?.rawValue ?? "Unknown error"]); return database.changes; }) > 0 else { return false; } DBChatStore.instance.newMessage(for: conversation.account, with: conversation.jid, timestamp: Date(timeIntervalSince1970: 0), itemType: nil, message: nil, state: .outgoing_error(.received, errorMessage: errorMessage ?? errorCondition?.rawValue ?? "Unknown error")) { self.itemUpdated(withId: itemId, for: conversation); } return true; } open func markOutgoingAsError(for conversation: ConversationKey, stanzaId: String, errorCondition: ErrorCondition?, errorMessage: String?) { _ = self.processOutgoingError(for: conversation, stanzaId: stanzaId, errorCondition: errorCondition, errorMessage: errorMessage); } open func countUnsentMessages() -> Int { return try! Database.main.reader({ database in return try database.count(query: .messagesCountUnsent, cached: false, params: []); }) } open func markAsRead(for conversation: Conversation, before: Date) { markAsRead(for: conversation.account, with: conversation.jid, before: before, sendMarkers: true); } let markedAsRead = PassthroughSubject(); struct MarkedAsRead { let account: BareJID; let jid: BareJID; let messages: [Message]; let before: Date; let onlyLocally: Bool; struct Message { let id: Int; let markableId: String?; } } open func markAsRead(for account: BareJID, with jid: BareJID, before: Date, sendMarkers: Bool) { let updatedRecords = try! Database.main.writer({ database -> [MarkedAsRead.Message] in let markedAsRead = try database.select(query: .messagesUnreadBefore, params: ["account": account, "jid": jid, "before": before]).mapAll({ curor in MarkedAsRead.Message(id: curor.int(for: "id")!, markableId: sendMarkers ? curor.string(for: "markable_id") : nil) }); if !markedAsRead.isEmpty { try database.update(query: .messagesMarkAsReadBefore, params: ["account": account, "jid": jid, "before": before]); } return markedAsRead; }) if !updatedRecords.isEmpty { DBChatStore.instance.markAsRead(for: account, with: jid, count: updatedRecords.count); markedAsRead.send(MarkedAsRead(account: account, jid: jid, messages: updatedRecords, before: before, onlyLocally: false)); } } // open func getItemId(for conversation: ConversationKey, stanzaId: String) -> Int? { // return dispatcher.sync { // return self.findItemId(for: conversation, originId: stanzaId, authorNickname: nil, participantId: nil); // } // } // open func itemPosition(for account: BareJID, with jid: BareJID, msgId: Int) -> Int? { // return dispatcher.sync { // return try! Database.main.reader({ database in // return try database.select(query: .messageFindPositionInChat, params: ["account": account, "jid": jid, "msgId": msgId, "showLinkPreviews": linkPreviews]).mapFirst({ $0.int(at: 0) }); // }) // } // } open func updateItemState(for conversation: ConversationKey, stanzaId: String, from oldState: ConversationEntryState, to newState: ConversationEntryState, withTimestamp timestamp: Date? = nil) { guard let msgId = self.findItemId(for: conversation, originId: stanzaId, sender: .none) else { return; } _ = self.updateItemState(for: conversation, itemId: msgId, from: oldState, to: newState, withTimestamp: timestamp); } open func updateItemState(for conversation: ConversationKey, itemId msgId: Int, from oldState: ConversationEntryState, to newState: ConversationEntryState, withTimestamp timestamp: Date?) -> Bool { guard try! Database.main.writer({ database -> Int in try database.update(query: .messageUpdateState, params: ["id": msgId, "oldState": oldState.code, "newState": newState.code, "newTimestamp": timestamp]); return database.changes; }) > 0 else { return false; } self.itemUpdated(withId: msgId, for: conversation); if oldState == .outgoing(.unsent) && newState != .outgoing(.unsent) { self.generatePreviews(forItem: msgId, conversation: conversation, state: newState, action: .new); } return true; } open func remove(item: ConversationEntry) { guard try! Database.main.writer({ database in try database.delete(query: .messageDelete, cached: false, params: ["id": item.id]); return database.changes; }) > 0 else { return; } self.itemRemoved(withId: item.id, for: item.conversation); self.removePreviews(idOfRelatedToItem: item.id); } private func removePreviews(idOfRelatedToItem masterId: Int) { let linkPreviews = try! Database.main.reader({ database in return try database.select(query: .messageFindLinkPreviewsForMessage, cached: false, params: ["master_id": masterId]).mapAll({ cursor -> (Int, BareJID, BareJID)? in guard let id: Int = cursor["id"], let account: BareJID = cursor["account"], let jid: BareJID = cursor["jid"] else { return nil; } return (id, account, jid); }) }) // for chat message we might have a link previews which we need to remove.. guard !linkPreviews.isEmpty else { return; } for (id, account, jid) in linkPreviews { // this is a preview and needs to be removed.. let removeLinkParams: [String: Any?] = ["id": id]; if try! Database.main.writer({ database -> Int in try database.delete(query: .messageDelete, cached: false, params: removeLinkParams); return database.changes; }) > 0 { self.itemRemoved(withId: id, for: ConversationKeyItem(account: account, jid: jid)); } } } public struct StableIds { let server: String?; let remote: String?; } func stableIds(forId id: Int) -> StableIds? { return try! Database.main.reader({ database in try database.select(query: .messageFindStableIds, cached: true, params: ["id": id]).mapFirst({ StableIds(server: $0.string(for: "server_msg_id"), remote: $0.string(for: "remote_msg_id")) }); }) } func originId(for key: ConversationKey, id: Int, completionHandler: @escaping (String)->Void ){ self.originId(for: key.account, with: key.jid, id: id, completionHandler: completionHandler); } func originId(for account: BareJID, with jid: BareJID, id: Int, completionHandler: @escaping (String)->Void ){ if let stanzaId = try! Database.main.reader({ dataase in try dataase.select(query: .messageFindMessageOriginId, cached: false, params: ["id": id]).mapFirst({ $0.string(for: "stanza_id")}); }) { DispatchQueue.main.async { completionHandler(stanzaId); } } } open func updateItem(for conversation: ConversationKey, id: Int, updateAppendix updateFn: @escaping (inout ChatAttachmentAppendix)->Void) { guard let oldItem = self.message(for: conversation, withId: id) else { return; } switch oldItem.payload { case .attachment(let url, var appendix): updateFn(&appendix); try! Database.main.writer({ database in try database.update(query: .messageUpdate, params: ["id": id, "appendix": appendix]); }) let item = ConversationEntry(id: oldItem.id, conversation: oldItem.conversation, timestamp: oldItem.timestamp, state: oldItem.state, sender: oldItem.sender, payload: .attachment(url: url, appendix: appendix), options: oldItem.options); events.send(.updated(item)); DispatchQueue.main.async { NotificationCenter.default.post(name: DBChatHistoryStore.MESSAGE_UPDATED, object: item); } default: return; } } open func updateItem(id: Int, updateAppendix updateFn: @escaping (inout ChatAttachmentAppendix)->Void) { guard let conversation = self.conversation(withId: id) else { return; } updateItem(for: conversation, id: id, updateAppendix: updateFn); } func loadUnsentMessage(for account: BareJID, completionHandler: @escaping (BareJID,[UnsentMessage])->Void) { let messages = try! Database.main.reader({ database in try database.select(query: .messagesFindUnsent, cached: false, params: ["account": account]).mapAll(UnsentMessage.from(cursor: )) }) completionHandler(account, messages); } fileprivate func itemUpdated(withId id: Int, for conversation: ConversationKey) { guard let item = self.message(for: conversation, withId: id) else { return; } events.send(.updated(item)); NotificationCenter.default.post(name: DBChatHistoryStore.MESSAGE_UPDATED, object: item); } fileprivate func itemRemoved(withId id: Int, for conversation: ConversationKey) { let entry = ConversationEntry(id: id, conversation: conversation, timestamp: Date(), state: .none, sender: .none, payload: .deleted, options: .none); events.send(.removed(entry)); NotificationCenter.default.post(name: DBChatHistoryStore.MESSAGE_REMOVED, object: entry); } func lastMessageTimestamp(for account: BareJID) -> Date? { return try! Database.main.reader({ database in return try database.select(query: .messagesLastTimestampForAccount, cached: false, params: ["account": account]).mapFirst({ $0.date(for: "timestamp") }); }); } open func history(for conversation: Conversation, queryType: ConversationLoadType) -> [ConversationEntry] { return try! Database.main.reader({ database in switch queryType { case .with(let id, let overhead): let position = try database.count(query: .messageFindPositionInChat, cached: true, params: ["account": conversation.account, "jid": conversation.jid, "msgId": id, "showLinkPreviews": linkPreviews]); let cursor = try database.select(query: .messagesFindForChat, params: ["account": conversation.account, "jid": conversation.jid, "offset": 0, "limit": position + overhead, "showLinkPreviews": self.linkPreviews]) return try cursor.mapAll({ cursor -> ConversationEntry? in self.itemFrom(cursor: cursor, for: conversation) }); case .unread(let overhead): let unread = try database.count(query: .messagesCountUnread, cached: true, params: ["account": conversation.account, "jid": conversation.jid]); let cursor = try database.select(query: .messagesFindForChat, params: ["account": conversation.account, "jid": conversation.jid, "offset": 0, "limit": unread + overhead, "showLinkPreviews": self.linkPreviews]) return try cursor.mapAll({ cursor -> ConversationEntry? in self.itemFrom(cursor: cursor, for: conversation) }); case .before(let item, let limit): let position = try database.count(query: .messageFindPositionInChat, cached: true, params: ["account": conversation.account, "jid": conversation.jid, "msgId": item.id, "showLinkPreviews": linkPreviews]); let cursor = try database.select(query: .messagesFindForChat, params: ["account": conversation.account, "jid": conversation.jid, "offset": position, "limit": limit, "showLinkPreviews": self.linkPreviews]) return try cursor.mapAll({ cursor -> ConversationEntry? in self.itemFrom(cursor: cursor, for: conversation) }); } }) } open func searchHistory(for account: BareJID? = nil, with jid: JID? = nil, search: String, completionHandler: @escaping ([ConversationEntry])->Void) { let tokens = search.unicodeScalars.split(whereSeparator: { (c) -> Bool in return CharacterSet.punctuationCharacters.contains(c) || CharacterSet.whitespacesAndNewlines.contains(c); }).map({ (s) -> String in return String(s) + "*"; }); let query = tokens.joined(separator: " + "); let items = try! Database.main.reader({ database in try database.select(query: .messageSearchHistory, params: ["account": account, "jid": jid, "query": query]).mapAll({ cursor -> ConversationEntry? in guard let account: BareJID = cursor["account"], let jid: BareJID = cursor["jid"] else { return nil; } return self.itemFrom(cursor: cursor, for: ConversationKeyItem(account: account, jid: jid)); }) }); completionHandler(items); } public func loadAttachments(for conversation: ConversationKey, completionHandler: @escaping ([ConversationEntry])->Void) { let params: [String: Any?] = ["account": conversation.account, "jid": conversation.jid]; let attachments = try! Database.main.reader({ database in return try database.select(query: .messagesFindChatAttachments, cached: false, params: params).mapAll({ cursor -> ConversationEntry? in return self.itemFrom(cursor: cursor, for: conversation); }) }) completionHandler(attachments); } fileprivate var linkPreviews: Bool { return Settings.linkPreviews; } private func itemFrom(cursor: Cursor, for conversation: ConversationKey) -> ConversationEntry? { let id: Int = cursor["id"]!; let state: ConversationEntryState = ConversationEntryState.from(cursor: cursor); let timestamp: Date = cursor["timestamp"]!; guard let entryType = ItemType(rawValue: cursor["item_type"]!) else { return nil; } var correctionTimestamp: Date? = cursor["correction_timestamp"]; if correctionTimestamp?.timeIntervalSince1970 == 0 { correctionTimestamp = nil; } guard let sender = senderFrom(cursor: cursor, for: conversation, direction: state.direction) else { return nil; } let options = ConversationEntry.Options(recipient: recipientFrom(cursor: cursor), encryption: DBChatHistoryStore.encryptionFrom(cursor: cursor), isMarkable: cursor.bool(for: "markable")); guard let payload = payloadFrom(cursor: cursor, entryType: entryType, correctionTimestamp: correctionTimestamp) else { return nil; } return .init(id: id, conversation: conversation, timestamp: timestamp, state: state, sender: sender, payload: payload, options: options); } private func payloadFrom(cursor: Cursor, entryType: ItemType, correctionTimestamp: Date?) -> ConversationEntryPayload? { switch entryType { case .location: guard let data: String = cursor["data"], let location = CLLocationCoordinate2D(geoUri: data) else { return nil; } return .location(location: location); case .message: guard let message: String = cursor["data"] else { return nil; } return .message(message: message, correctionTimestamp: correctionTimestamp); case .messageRetracted: return .messageRetracted; case .invitation: guard let appendix: ChatInvitationAppendix = cursor.object(for: "appendix")else { return nil; } return .invitation(message: cursor["data"], appendix: appendix); case .attachment: guard let url: String = cursor["data"] else { return nil; } let appendix = cursor.object(for: "appendix") ?? ChatAttachmentAppendix(); return .attachment(url: url, appendix: appendix); case .attachmentRetracted: return nil; case .linkPreview: guard let url: String = cursor["data"] else { return nil; } return .linkPreview(url: url); } } private func recipientFrom(cursor: Cursor) -> ConversationEntryRecipient { guard let nickname = cursor.string(for: "recipient_nickname") else { return .none; } return .occupant(nickname: nickname); } private func senderFrom(cursor: Cursor, for conversation: ConversationKey, direction: MessageDirection) -> ConversationEntrySender? { // guessing based on conversation is not always possible, ie. for plain key (not Conversation) switch conversation { case is Chat: switch direction { case .outgoing: return .me(conversation: conversation); case .incoming: return .buddy(conversation: conversation); } case is Room: guard let nickname: String = cursor["author_nickname"] else { return nil; } return .occupant(nickname: nickname, jid: cursor["author_jid"]); case is Channel: guard let participantId: String = cursor["participant_id"], let nickname: String = cursor["author_nickname"] else { guard let nickname: String = cursor["author_nickname"] else { return .buddy(nickname: ""); } return .occupant(nickname: nickname, jid: cursor["author_jid"]); } return .participant(id: participantId, nickname: nickname, jid: cursor["author_jid"]); default: if let participantId: String = cursor["participant_id"], let nickname: String = cursor["author_nickname"] { return .participant(id: participantId, nickname: nickname, jid: cursor["author_jid"]); } else if let nickname: String = cursor["author_nickname"] { return .occupant(nickname: nickname, jid: cursor["author_jid"]); } else { switch direction { case .outgoing: return .me(conversation: conversation); case .incoming: return .buddy(conversation: conversation); } } } } public static func encryptionFrom(cursor: Cursor, encryptionKey: String = "encryption", fingerprintKey: String = "fingerprint") -> ConversationEntryEncryption { switch MessageEncryption(rawValue: cursor[encryptionKey] ?? 0) ?? .none { case .none: return .none; case .decryptionFailed: let code = Int(cursor.string(for: fingerprintKey) ?? "") ?? 0; return .decryptionFailed(errorCode: code); case .notForThisDevice: return .notForThisDevice; case .decrypted: return .decrypted(fingerprint: cursor[fingerprintKey]); } } } extension ConversationEntryState { static func from(cursor: Cursor) -> ConversationEntryState { let stateInt: Int = cursor["state"]!; return ConversationEntryState.from(code: stateInt, errorMessage: cursor["error"]); } } public enum ItemType: Int { case message = 0 case attachment = 1 // how about new type called link preview? this way we would have a far less data kept in a single item.. // we could even have them separated to the new item/entry during adding message to the store.. case linkPreview = 2 // with that in place we can have separate metadata kept "per" message as it is only one, so message id can be id of associated metadata.. case invitation = 3 case messageRetracted = 4 case attachmentRetracted = 5; case location = 6; } class UnsentMessage { let jid: BareJID; let type: ItemType; let data: String; let stanzaId: String; let encryption: MessageEncryption; let correctionStanzaId: String?; init(jid: BareJID, type: ItemType, data: String, stanzaId: String, encryption: MessageEncryption, correctionStanzaId: String?) { self.jid = jid; self.type = type; self.data = data; self.stanzaId = stanzaId; self.encryption = encryption; self.correctionStanzaId = correctionStanzaId; } static func from(cursor: Cursor) -> UnsentMessage? { guard let jid = cursor.bareJid(for: "jid"), let type = ItemType(rawValue: cursor.int(for: "item_type")!), let data = cursor.string(for: "data"), let stanzaId = cursor.string(for: "stanza_id"), let encryption = MessageEncryption(rawValue: cursor.int(for: "encryption") ?? 0) else { return nil; } return UnsentMessage(jid: jid, type: type, data: data, stanzaId: stanzaId, encryption: encryption, correctionStanzaId: cursor.string(for: "correction_stanza_id")); } } ================================================ FILE: SiskinIM/database/DBChatHistorySyncStore.swift ================================================ // // DBChatHistorySyncStore.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import os import TigaseSQLite3 import Combine extension Query { static let mamSyncInsertPeriod = Query("INSERT INTO chat_history_sync (id, account, component, from_timestamp, from_id, to_timestamp) VALUES (:id, :account, :component, :from_timestamp, :from_id, :to_timestamp)"); static let mamSyncFindPeriodsForAccount = Query("SELECT id, account, component, from_timestamp, from_id, to_timestamp FROM chat_history_sync WHERE account = :account AND component IS NULL ORDER BY from_timestamp ASC"); static let mamSyncFindPeriodsForAccountWith = Query("SELECT id, account, component, from_timestamp, from_id, to_timestamp FROM chat_history_sync WHERE account = :account AND component = :component ORDER BY from_timestamp ASC"); static let mamSyncDeletePeriod = Query("DELETE FROM chat_history_sync WHERE id = :id"); static let mamSyncDeletePeriodsForAccount = Query("DELETE FROM chat_history_sync WHERE account = :account"); static let mamSyncDeletePeriodsForAccountWith = Query("DELETE FROM chat_history_sync WHERE account = :account AND component = :component"); static let mamSyncUpdatePeriodAfter = Query("UPDATE chat_history_sync SET from_id = :after WHERE id = :id"); static let mamSyncUpdatePeriodTo = Query("UPDATE chat_history_sync SET to_timestamp = :to_timestamp WHERE id = :id"); } class DBChatHistorySyncStore { static let instance = DBChatHistorySyncStore() private var cancellables: Set = []; init() { AccountManager.accountEventsPublisher.sink(receiveValue: { [weak self] event in self?.accountChanged(event); }).store(in: &cancellables) } func accountChanged(_ event: AccountManager.Event) { switch event { case .removed(let account): removeSyncPeriods(forAccount: account.name); default: break; } } func addSyncPeriod(_ period: Period) { if let last = loadSyncPeriods(forAccount: period.account, component: period.component).last, last.from <= period.from { if let lastTo = last.to { // we only need to update `to` value if lastTo >= period.from { os_log("updating sync period to for account %s and component %s", log: .chatHistorySync, type: .debug, period.account.stringValue, period.component?.stringValue ?? "nil"); let newTo = period.to == nil ? nil : max(lastTo, period.to!); try! Database.main.writer({ database in try database.update(query: .mamSyncUpdatePeriodTo, cached: false, params: ["id": last.id.uuidString, "to_timestamp": newTo]); }) return; } else { // we need a new record.. } } else { // lastTo is nil, so sync up to newest.. return; } } os_log("adding sync period %s for account %s and component %s from %{time_t}d to %{time_t}d", log: .chatHistorySync, type: .debug, period.id.uuidString, period.account.stringValue, period.component?.stringValue ?? "nil", time_t(period.from.timeIntervalSince1970), time_t(period.to?.timeIntervalSince1970 ?? 0)); try! Database.main.writer({ database in try database.insert(query: .mamSyncInsertPeriod, cached: false, params: ["id": period.id.uuidString, "account": period.account, "component": period.component, "from_timestamp": period.from, "to_timestamp": period.to]); }) } func loadSyncPeriods(forAccount account: BareJID, component: BareJID?) -> [Period] { var params = ["account": account]; if let component = component { params["component"] = component; } // how about periods with less than a few minutes/seconds apart? should we merge them? let query: Query = component == nil ? .mamSyncFindPeriodsForAccount : .mamSyncFindPeriodsForAccountWith; let periods = try! Database.main.reader({ database in try database.select(query: query, cached: false, params: params).mapAll({ cursor -> Period? in return Period(id: UUID(uuidString: cursor["id"]!)!, account: cursor["account"]!, component: cursor["component"], from: cursor["from_timestamp"]!, after: cursor["from_id"], to: cursor["to_timestamp"]); }) }) os_log("loaded %d sync periods for account %s and component %s", log: .chatHistorySync, type: .debug, periods.count, account.stringValue, component?.stringValue ?? "nil"); return periods; } func removeSyncPerod(_ period: Period) { os_log("removing sync period %s for account %s and component %s", log: .chatHistorySync, type: .debug, period.id.uuidString, period.account.stringValue, period.component?.stringValue ?? "nil"); try! Database.main.writer({ database in try database.delete(query: .mamSyncDeletePeriod, cached: false, params: ["id": period.id.uuidString]) }) } func removeSyncPeriods(forAccount account: BareJID, component: BareJID? = nil) { try! Database.main.writer({ database in if let component = component { try database.delete(query: .mamSyncDeletePeriodsForAccountWith, cached: false, params: ["account": account, "component": component]); } else { try database.delete(query: .mamSyncDeletePeriodsForAccount, cached: false, params: ["account": account]); } }) } func updatePeriod(_ period: Period, after: String) { os_log("updating sync period %s for account %s and component %s to after %s", log: .chatHistorySync, type: .debug, period.id.uuidString, period.account.stringValue, period.component?.stringValue ?? "nil", after); try! Database.main.writer({ database in try database.update(query: .mamSyncUpdatePeriodAfter, cached: false, params: ["id": period.id.uuidString, "after": after]); }) } class Period { let id: UUID; let account: BareJID; let component: BareJID?; let from: Date; var after: String?; let to: Date?; init(id: UUID = UUID(), account: BareJID, component: BareJID? = nil, from: Date, after: String?, to: Date?) { self.id = id; self.account = account; self.component = component; self.from = from; self.after = after; self.to = to; } } } ================================================ FILE: SiskinIM/database/DBChatMarkersStore.swift ================================================ // // DBChatMarkersStore.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import TigaseSQLite3 extension Query { static let markerFind = Query("SELECT type, timestamp FROM chat_markers WHERE account = :account AND jid = :jid AND sender_nick = :sender_nick AND sender_id = :sender_id AND sender_jid = :sender_jid"); static let markerUpdate = Query("UPDATE chat_markers SET timestamp = :timestamp, type = :type WHERE account = :account AND jid = :jid AND sender_nick = :sender_nick AND sender_id = :sender_id AND sender_jid = :sender_jid"); static let markerInsert = Query("INSERT INTO chat_markers (account, jid, sender_nick, sender_id, sender_jid, timestamp, type) VALUES (:account, :jid, :sender_nick, :sender_id, :sender_jid, :timestamp, :type)"); static let markersList = Query("SELECT sender_nick, sender_id, sender_jid, timestamp, type FROM chat_markers WHERE account = :account AND jid = :jid"); } public class DBChatMarkersStore { public static let instance = DBChatMarkersStore(); private func queryParams(conversation: ConversationKey, sender: ConversationEntrySender) -> [String: Any?]? { switch sender { case .none, .channel: return nil; case .me(_): return ["account": conversation.account, "jid": conversation.jid, "sender_nick": "", "sender_id": "", "sender_jid": conversation.account]; case .buddy(_): return ["account": conversation.account, "jid": conversation.jid, "sender_nick": "", "sender_id": "", "sender_jid": conversation.jid]; case .occupant(let nickname, let jid): return ["account": conversation.account, "jid": conversation.jid, "sender_nick": nickname, "sender_id": "", "sender_jid": jid?.stringValue ?? ""]; case .participant(let id, let nickname, let jid): return ["account": conversation.account, "jid": conversation.jid, "sender_nick": nickname, "sender_id": id, "sender_jid": jid?.stringValue ?? ""]; } } private var enqueuedChatMarkersQueue = DispatchQueue(label: "EnqueuedChatMarkers"); private var enqueuedChatMarkers: [ConversationBase: EnqueuedChatMarkers] = [:]; private class EnqueuedChatMarkers { private(set) var queue: [EnqueuedChatMarker] = []; func append(sender: ConversationEntrySender, id: String, type: ChatMarker.MarkerType) { queue.append(.init(sender: sender, id: id, type: type)); } func replayQueue(for conversation: ConversationKey) { for item in queue { DBChatMarkersStore.instance.mark(conversation: conversation, before: item.id, as: item.type, by: item.sender, enqueueIfMessageNotFound: false); } } } private struct EnqueuedChatMarker { let sender: ConversationEntrySender; let id: String; let type: ChatMarker.MarkerType; } public func awaitingSync(for room: Room) { enqueuedChatMarkersQueue.async { self.enqueuedChatMarkers[room] = EnqueuedChatMarkers(); } } public func syncCompleted(forAccount: BareJID, with jid: BareJID) { enqueuedChatMarkersQueue.sync { if let room = DBChatStore.instance.conversation(for: forAccount, with: jid) as? Room { self.enqueuedChatMarkers.removeValue(forKey: room)?.replayQueue(for: room); } } } private func findItemId(for conversation: ConversationKey, id: String, sender: ConversationEntrySender) -> Int? { if sender.isGroupchat { if let itemId = DBChatHistoryStore.instance.findItemId(for: conversation, remoteMsgId: id) { return itemId; } // we are allowing to fall back to check origin-id as this is what Conversations does.. } return DBChatHistoryStore.instance.findItemId(for: conversation, originId: id, sender: .none); } public func mark(conversation: ConversationKey, before id: String, as type: ChatMarker.MarkerType, by sender: ConversationEntrySender, enqueueIfMessageNotFound: Bool = true) { guard var params = queryParams(conversation: conversation, sender: sender) else { return; } guard let msgId = findItemId(for: conversation, id: id, sender: sender) else { if enqueueIfMessageNotFound && sender.isGroupchat && (conversation is Room), let groupchat = conversation as? ConversationBase, groupchat.isLocal(sender: sender) { enqueuedChatMarkersQueue.async { if let queue = self.enqueuedChatMarkers[groupchat] { queue.append(sender: sender, id: id, type: type) } } } return; } guard let message = DBChatHistoryStore.instance.message(for: conversation, withId: msgId) else { return; } let timestamp: Date = message.timestamp; if let (oldType, oldTimestamp) = try! Database.main.reader({ database in try database.select(query: .markerFind, params: params).mapFirst({ (ChatMarker.MarkerType(rawValue: $0.int(for: "type")!)!, $0.date(for: "timestamp")!) }); }) { switch type { case .received: guard oldTimestamp < timestamp else { return; } case .displayed: guard oldTimestamp < timestamp || (oldTimestamp == timestamp && oldType < type) else { return; } } try! Database.main.writer({ database in params["type"] = type.rawValue; params["timestamp"] = timestamp; try database.update(query: .markerUpdate, params: params); }) } else { try! Database.main.writer({ database in params["type"] = type.rawValue; params["timestamp"] = timestamp; try database.insert(query: .markerInsert, params: params); }) } if let conv = (conversation as? Conversation) ?? DBChatStore.instance.conversation(for: conversation.account, with: conversation.jid) { conv.mark(as: type, before: message.timestamp, by: sender); if conv.isLocal(sender: sender) { if type == .displayed { DBChatHistoryStore.instance.markAsRead(for: conv, before: timestamp); } MessageEventHandler.instance.cancelReceived(for: conv, before: timestamp); } } } public func markers(for conversation: ConversationKey) -> [ChatMarker] { return try! Database.main.reader({ database in try database.select(query: .markersList, params: ["account": conversation.account, "jid": conversation.jid]).mapAll({ self.charMarker(fromCursor: $0, conversation: conversation)}); }); } private func charMarker(fromCursor c: Cursor, conversation: ConversationKey) -> ChatMarker? { guard let type = ChatMarker.MarkerType(rawValue: c.int(for: "type")!), let timestamp = c.date(for: "timestamp"), let jidStr = c.string(for: "sender_jid"), let nick = c.string(for: "sender_nick"), let id = c.string(for: "sender_id") else { return nil; } if nick.isEmpty { if conversation.account == BareJID(jidStr) { return ChatMarker(sender: .me(conversation: conversation), timestamp: timestamp, type: type); } else { return ChatMarker(sender: .buddy(conversation: conversation), timestamp: timestamp, type: type); } } else { var jid: BareJID?; if !jidStr.isEmpty { jid = BareJID(jidStr); } if id.isEmpty { return ChatMarker(sender: .occupant(nickname: nick, jid: jid), timestamp: timestamp, type: type); } else { return ChatMarker(sender: .participant(id: id, nickname: nick, jid: jid), timestamp: timestamp, type: type); } } } } ================================================ FILE: SiskinIM/database/DBChatStore+ChannelStore.swift ================================================ // // DBChatStore+ChannelStore.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin extension DBChatStore: ChannelStore { public typealias Channel = Siskin.Channel public func channels(for context: Context) -> [Channel] { return convert(items: conversations(for: context.userBareJid)); } public func channel(for context: Context, with jid: BareJID) -> Channel? { return conversation(for: context.userBareJid, with: jid) as? Channel; } public func createChannel(for context: Context, with channelJid: BareJID, participantId: String, nick: String?, state: ChannelState) -> ConversationCreateResult { self.conversationsLifecycleQueue.sync { if let channel = channel(for: context, with: channelJid) { return .found(channel); } let account = context.userBareJid; guard let channel: Channel = createConversation(for: account, with: channelJid, execute: { let timestamp = Date(); let options = ChannelOptions(participantId: participantId, nick: nick, state: state); let id = try! self.openConversation(account: account, jid: channelJid, type: .channel, timestamp: timestamp, options: options); let channel = Channel(dispatcher: self.conversationDispatcher, context: context, channelJid: channelJid, id: id, timestamp: timestamp, lastActivity: lastActivity(for: account, jid: channelJid), unread: 0, options: options, creationTimestamp: timestamp); return channel; }) else { if let channel = self.channel(for: context, with: channelJid) { return .found(channel); } return .none; } return .created(channel); } } public func close(channel: Channel) -> Bool { return close(conversation: channel); } } ================================================ FILE: SiskinIM/database/DBChatStore+ChatStore.swift ================================================ // // DBChatStore+ChatStore.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin extension DBChatStore: ChatStore { public typealias Chat = Siskin.Chat public func chats(for context: Context) -> [Chat] { return convert(items: self.conversations(for: context.userBareJid)); } public func chat(for context: Context, with jid: BareJID) -> Chat? { return conversation(for: context.userBareJid, with: jid) as? Chat; } public func createChat(for context: Context, with jid: BareJID) -> ConversationCreateResult { self.conversationsLifecycleQueue.sync { if let chat = chat(for: context, with: jid) { return .found(chat); } let account = context.userBareJid; guard let chat: Chat = createConversation(for: account, with: jid, execute: { let timestamp = Date(); let id = try! self.openConversation(account: account, jid: jid, type: .chat, timestamp: timestamp, options: nil); let chat = Chat(dispatcher: self.conversationDispatcher, context: context, jid: jid, id: id, timestamp: timestamp, lastActivity: lastActivity(for: account, jid: jid), unread: 0, options: ChatOptions()); return chat; }) else { if let chat = self.chat(for: context, with: jid) { return .found(chat); } return .none; } return .created(chat); } } public func close(chat: Chat) -> Bool { return close(conversation: chat); } } ================================================ FILE: SiskinIM/database/DBChatStore+RoomStore.swift ================================================ // // DBChatStore+RoomStore.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin extension DBChatStore: RoomStore { public typealias Room = Siskin.Room public func rooms(for context: Context) -> [Room] { return convert(items: self.conversations(for: context.userBareJid)); } public func room(for context: Context, with jid: BareJID) -> Room? { return conversation(for: context.userBareJid, with: jid) as? Room; } public func createRoom(for context: Context, with jid: BareJID, nickname: String, password: String?) -> ConversationCreateResult { self.conversationsLifecycleQueue.sync { if let room = room(for: context, with: jid) { return .found(room); } let account = context.userBareJid; guard let room: Room = createConversation(for: account, with: jid, execute: { let timestamp = Date(); let options = RoomOptions(nickname: nickname, password: password); let id = try! self.openConversation(account: account, jid: jid, type: .room, timestamp: timestamp, options: options); let room = Room(dispatcher: self.conversationDispatcher, context: context, jid: jid, id: id, timestamp: timestamp, lastActivity: lastActivity(for: account, jid: jid), unread: 0, options: options); return room; }) else { if let room = self.room(for: context, with: jid) { return .found(room); } return .none; } return .created(room); } } public func close(room: Room) -> Bool { return close(conversation: room); } } ================================================ FILE: SiskinIM/database/DBChatStore.swift ================================================ // // DBChatStore.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import TigaseSQLite3 import Combine import Shared extension Query { static let chatInsert = Query("INSERT INTO chats (account, jid, timestamp, type, options) VALUES (:account, :jid, :timestamp, :type, :options)"); static let chatDelete = Query("DELETE FROM chats WHERE id = :id"); static let chatFindAllForAccount = Query("SELECT c.id, c.type, c.jid, c.name, c.nickname, c.password, c.timestamp as creation_timestamp, last.timestamp as timestamp, last1.item_type, last1.data, last1.state, last1.encryption as lastEncryption, last1.fingerprint as lastFingerprint, (SELECT count(id) FROM chat_history ch2 WHERE ch2.account = last.account AND ch2.jid = last.jid AND ch2.state IN (\(ConversationEntryState.incoming(.received).rawValue), \(ConversationEntryState.incoming_error(.received).rawValue), \(ConversationEntryState.outgoing_error(.received).rawValue)) AND ch2.item_type IN (\(ItemType.message.rawValue), \(ItemType.attachment.rawValue),\(ItemType.location.rawValue))) as unread, c.options, last1.author_nickname FROM chats c LEFT JOIN (SELECT ch.account, ch.jid, max(ch.timestamp) as timestamp FROM chat_history ch WHERE ch.account = :account AND ch.item_type IN (\(ItemType.message.rawValue), \(ItemType.attachment.rawValue),\(ItemType.location.rawValue)) GROUP BY ch.account, ch.jid) last ON c.jid = last.jid AND c.account = last.account LEFT JOIN chat_history last1 ON last1.account = c.account AND last1.jid = c.jid AND last1.timestamp = last.timestamp AND last1.item_type IN (\(ItemType.message.rawValue), \(ItemType.attachment.rawValue),\(ItemType.location.rawValue)) WHERE c.account = :account"); static let chatUpdateOptions = Query("UPDATE chats SET options = :options WHERE account = :account AND jid = :jid"); static let chatUpdateName = Query("UPDATE chats SET name = :name WHERE account = :account AND jid = :jid"); static let chatUpdateMessageDraft = Query("UPDATE chats SET message_draft = ? WHERE account = ? AND jid = ? AND IFNULL(message_draft, '') <> IFNULL(?, '')"); static let chatFindMessageDraft = Query("SELECT message_draft FROM chats WHERE account = :account AND jid = :jid"); static let chatFindLastActivity = Query("SELECT last.timestamp as timestamp, last1.item_type, last1.data, last1.encryption, last1.fingerprint, (SELECT count(id) FROM chat_history ch2 WHERE ch2.account = last.account AND ch2.jid = last.jid AND ch2.state IN (\(ConversationEntryState.incoming(.received).rawValue), \(ConversationEntryState.incoming_error(.received).rawValue), \(ConversationEntryState.outgoing_error(.received).rawValue))) as unread, last1.author_nickname FROM (SELECT ch.account, ch.jid, max(ch.timestamp) as timestamp FROM chat_history ch WHERE ch.account = :account AND ch.jid = :jid AND ch.item_type IN (\(ItemType.message.rawValue), \(ItemType.attachment.rawValue),\(ItemType.location.rawValue)) GROUP BY ch.account, ch.jid) last LEFT JOIN chat_history last1 ON last1.account = last.account AND last1.jid = last.jid AND last1.timestamp = last.timestamp AND last1.item_type IN (\(ItemType.message.rawValue), \(ItemType.attachment.rawValue),\(ItemType.location.rawValue))"); } open class DBChatStore: ContextLifecycleAware { static let instance: DBChatStore = DBChatStore.init(); public let conversationDispatcher = QueueDispatcher(label: "ConversationDispatcher", attributes: .concurrent) public let dispatcher: QueueDispatcher; private var accountChats = [BareJID: AccountConversations](); @Published public private(set) var conversations: [Conversation] = []; private let conversationsDispatcher = QueueDispatcher(label: "conversationsDispatcher"); public let conversationsLifecycleQueue = QueueDispatcher(label: "conversationsLifecycle"); @Published fileprivate(set) var unreadMessagesCount: Int = 0; public let conversationsEventsPublisher = PassthroughSubject(); private var cancellables: Set = []; public init() { self.dispatcher = QueueDispatcher(label: "db_chat_store"); } public func accountChats(for account: BareJID) -> AccountConversations? { return dispatcher.sync { return accountChats[account]; } } // public func conversations() -> [Conversation] { // return dispatcher.sync(execute: { // return accountChats.values; // }).flatMap({ $0.items }); // } public func conversations(for account: BareJID) -> [Conversation] { return dispatcher.sync(execute: { return accountChats[account]; })?.items ?? []; } public func conversation(for account: BareJID, with jid: BareJID) -> Conversation? { return dispatcher.sync(execute: { return accountChats[account]; })?.get(with: jid); } public func close(conversation: Conversation) -> Bool { let result = dispatcher.sync(execute: { return accountChats[conversation.account]; })?.close(conversation: conversation, execute: { self.destroy(conversation: conversation); }) ?? false; if result { if conversation.unread > 0 && !self.isMuted(conversation: conversation) { self.unreadMessagesCount = max(self.unreadMessagesCount - conversation.unread, 0); DBChatHistoryStore.instance.markAsRead(for: conversation, before: Date()); } } return result; } func convert(items: [Conversation]) -> [T] { return items.filter({ $0 is T }).map({ $0 as! T}); } public func createConversation(for account: BareJID, with jid: BareJID, execute: ()->Conversation) -> T? { if let conversation = dispatcher.sync(execute: { return accountChats[account]; })?.open(with: jid, execute: { let conversation = execute(); self.conversationDispatcher.async { self.conversations.append(conversation); } self.conversationsEventsPublisher.send(.created(conversation)); return conversation; }) as? T { return conversation; } return nil; } public func initialize(context: Context) { loadChats(for: context.userBareJid, context: context); } public func deinitialize(context: Context) { unloadChats(for: context.userBareJid); } func openConversation(account: BareJID, jid: BareJID, type: ConversationType, timestamp: Date = Date(), options: ChatOptionsProtocol?) throws -> Int { let params: [String: Any?] = [ "account": account, "jid": jid, "timestamp": Date(), "type": type.rawValue, "options": options]; return try Database.main.writer({ database in try database.insert(query: .chatInsert, params: params); return database.lastInsertedRowId!; }) } func isMuted(conversation: Conversation) -> Bool { return conversation.notifications == .none; } func closeAll(for account: BareJID) { dispatcher.async { let items = self.conversations(for: account) for conversation in items { _ = self.close(conversation: conversation); } } } func process(chatState remoteChatState: ChatState, for account: BareJID, with jid: BareJID) { dispatcher.async { if let chat = self.conversation(for: account, with: jid) as? Chat { chat.update(remoteChatState: remoteChatState); } } } func newMessage(for account: BareJID, with jid: BareJID, timestamp: Date, itemType: ItemType?, message: String?, state: ConversationEntryState, remoteChatState: ChatState? = nil, senderNickname: String? = nil, completionHandler: @escaping ()->Void) { let lastActivity = LastChatActivity.from(itemType: itemType, data: message, direction: state.direction, sender: senderNickname); newMessage(for: account, with: jid, timestamp: timestamp, lastActivity: lastActivity, state: state, remoteChatState: remoteChatState, completionHandler: completionHandler); } func newMessage(for account: BareJID, with jid: BareJID, timestamp: Date, lastActivity: LastChatActivity?, state: ConversationEntryState, remoteChatState: ChatState? = nil, completionHandler: @escaping ()->Void) { dispatcher.async { if let conversation = self.conversation(for: account, with: jid) { let unread = lastActivity != nil && state.isUnread; let updated = conversation.update(lastActivity: lastActivity, timestamp: timestamp, isUnread: unread); if unread && !self.isMuted(conversation: conversation) { self.unreadMessagesCount = self.unreadMessagesCount + 1; } if updated { if let chat = conversation as? Chat { if remoteChatState != nil { chat.update(remoteChatState: remoteChatState); } else { if chat.remoteChatState == .composing { chat.update(remoteChatState: .active); } } } self.refreshConversationsList(); } } completionHandler(); } } func markAsRead(for account: BareJID, with jid: BareJID, count: Int? = nil) { dispatcher.async { if let conversation = self.conversation(for: account, with: jid) { let unread = conversation.unread; if conversation.markAsRead(count: count ?? unread) { if !self.isMuted(conversation: conversation) { self.unreadMessagesCount = max(self.unreadMessagesCount - (count ?? unread), 0); } } } } } func resetChatStates(for account: BareJID) { dispatcher.async { for conversation in self.conversations(for: account) { if let chat = conversation as? Chat { chat.update(remoteChatState: nil); chat.localChatState = .active; } } } } func refreshConversationsList() { self.conversationsDispatcher.sync { let items = self.conversations; self.conversations = items; } } func messageDraft(for account: BareJID, with jid: BareJID, completionHandler: @escaping (String?)->Void) { dispatcher.async { let text = try! Database.main.reader({ database -> String? in return try database.select(query: .chatFindMessageDraft, params: ["account": account, "jid": jid]).mapFirst({ $0.string(for: "message_draft") }); }) completionHandler(text); } } func storeMessage(draft: String?, for account: BareJID, with jid: BareJID) { dispatcher.async { try! Database.main.writer({ database in try database.update(query: .chatUpdateMessageDraft, params: [draft, account, jid, draft]); }) } } private func destroy(conversation: Conversation) { conversationsDispatcher.async { self.conversations.removeAll(where: { $0 === conversation }) } conversationsEventsPublisher.send(.destroyed(conversation)); try! Database.main.writer({ database in try database.delete(query: .chatDelete, params: ["id": conversation.id]); }); if conversation is Room { DispatchQueue.global().async { DBChatHistorySyncStore.instance.removeSyncPeriods(forAccount: conversation.account, component: conversation.jid); } } } public func lastActivity(for account: BareJID, jid: BareJID) -> LastChatActivity? { return dispatcher.sync { return try! Database.main.reader({ database in try database.select(query: .chatFindLastActivity, params: ["account": account, "jid": jid]).mapFirst({ cursor -> LastChatActivity? in let encryption = DBChatHistoryStore.encryptionFrom(cursor: cursor); let authorNickname: String? = cursor.string(for: "author_nickname"); switch encryption { case .decrypted(_), .none: let state = ConversationEntryState.from(code: cursor.int(for: "state") ?? 0, errorMessage: nil); return LastChatActivity.from(itemType: ItemType(rawValue: cursor.int(for: "item_type") ?? -1), data: cursor["data"], direction: state.direction, sender: authorNickname); default: if let message = encryption.message() { return .message(message, direction: .incoming, sender: nil); } else { return nil; } } }); }) } } @available(*, deprecated, renamed: "lastActivity") public func getLastActivity(for account: BareJID, jid: BareJID) -> LastChatActivity? { lastActivity(for: account, jid: jid); } func loadChats(for account: BareJID, context: Context) { dispatcher.async { guard self.accountChats[account] == nil else { return; } let conversations = try! Database.main.reader({ database in return try database.select(query: .chatFindAllForAccount, params: ["account": account]).mapAll({ cursor -> Conversation? in guard let type = ConversationType(rawValue: cursor.int(for: "type") ?? -1) else { return nil; } let id = cursor.int(for: "id")!; let unread = cursor.int(for: "unread") ?? 0; guard let jid = cursor.bareJid(for: "jid"), let creationTimestamp = cursor.date(for: "creation_timestamp") else { return nil; } let lastMessageTimestamp = cursor.date(for: "timestamp"); let lastMessageEncryption = DBChatHistoryStore.encryptionFrom(cursor: cursor, encryptionKey: "lastEncryption", fingerprintKey: "lastFingerprint"); let lastActivity = cursor.int(for: "state") == nil ? nil : LastChatActivity.from(itemType: ItemType(rawValue: cursor.int(for: "item_type") ?? -1), data: lastMessageEncryption.message() ?? cursor.string(for: "data"), direction: ConversationEntryState.from(code: cursor.int(for: "state") ?? -1, errorMessage: nil).direction, sender: cursor.string(for: "author_nickname")); let timestamp = lastMessageTimestamp == nil ? creationTimestamp : (creationTimestamp.compare(lastMessageTimestamp!) == .orderedAscending ? lastMessageTimestamp! : creationTimestamp); switch type { case .chat: let options: ChatOptions? = cursor.object(for: "options"); return Chat(dispatcher: self.conversationDispatcher, context: context, jid: jid, id: id, timestamp: timestamp, lastActivity: lastActivity, unread: unread, options: options ?? ChatOptions()); case .room: guard let options: RoomOptions = cursor.object(for: "options") else { return nil; } let room = Room(dispatcher: self.conversationDispatcher, context: context, jid: jid, id: id, timestamp: timestamp, lastActivity: lastActivity, unread: unread, options: options); return room; case .channel: guard let options: ChannelOptions = cursor.object(for: "options") else { return nil; } return Channel(dispatcher: self.conversationDispatcher, context: context, channelJid: jid, id: id, timestamp: timestamp, lastActivity: lastActivity, unread: unread, options: options, creationTimestamp: cursor.date(for: "creation_timestamp")!); } }); }) let accountConversation = AccountConversations(items: conversations); self.accountChats[account] = accountConversation; var unread = 0; for item in conversations { if !self.isMuted(conversation: item) { unread = unread + item.unread; } // NotificationCenter.default.post(name: DBChatStore.CHAT_OPENED, object: item); } let items = accountConversation.items; self.conversationsDispatcher.async { self.conversations.append(contentsOf: items); } if unread > 0 { self.unreadMessagesCount = self.unreadMessagesCount + unread; } } } func unloadChats(for account: BareJID) { dispatcher.async { guard let accountChats = self.accountChats.removeValue(forKey: account) else { return; } var unread = 0; accountChats.items.forEach { item in if !self.isMuted(conversation: item) { unread = unread + item.unread; } } let removed = accountChats.items; self.conversationsDispatcher.async { self.conversations.removeAll(where: { it in removed.contains(where: { it === $0 }) }) } if unread > 0 { self.unreadMessagesCount = max(self.unreadMessagesCount - unread, 0); } } } private func calculateChange(_ old: ConversationNotification, _ new: ConversationNotification) -> Bool? { if old == .none && new != .none { return true; } else if old != .none && new == .none { return false; } return nil; } open func update(options: ChatOptionsProtocol, for conversation: Conversation) { let notificationChange = calculateChange(conversation.notifications, options.notifications); dispatcher.async { try! Database.main.writer({ database in try database.update(query: .chatUpdateOptions, params: ["options": options, "account": conversation.account, "jid": conversation.jid]); }) if conversation.unread > 0, let change = notificationChange { if change { self.unreadMessagesCount = self.unreadMessagesCount + conversation.unread; } else { self.unreadMessagesCount = max(self.unreadMessagesCount - conversation.unread, 0); } } } } public enum ConversationEvent { case created(Conversation) case destroyed(Conversation) } } ================================================ FILE: SiskinIM/database/DBOMEMOStore.swift ================================================ // // DBOMEMOStore.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import MartinOMEMO import TigaseSQLite3 extension Query { static let omemoKeyPairForAccount = Query("SELECT key FROM omemo_identities WHERE account = :account AND name = :name AND device_id = :deviceId AND own = 1"); static let omemoKeyPairExists = Query("SELECT count(1) FROM omemo_identities WHERE account = :account AND name = :name AND fingerprint = :fingerprint"); static let omemoKeyPairInsert = Query("INSERT INTO omemo_identities (account, name, device_id, key, fingerprint, own, status) VALUES (:account,:name,:deviceId,:key,:fingerprint,:own,:status) ON CONFLICT(account, name, fingerprint) DO UPDATE SET device_id = :deviceId, status = :status"); static let omemoKeyPairLoadStatus = Query("SELECT status FROM omemo_identities WHERE account = :account AND name = :name AND device_id = :deviceId"); static let omemoKeyPairUpdateStatus = Query("UPDATE omemo_identities SET status = :status WHERE account = :account AND name = :name AND device_id = :deviceId"); static let omemoIdentitiesWipe = Query("DELETE FROM omemo_identities WHERE account = :account"); static let omemoIdentityFind = Query("SELECT device_id, fingerprint, status, key, own FROM omemo_identities WHERE account = :account AND name = :name"); static let omemoIdentityFingerprintFind = Query("SELECT fingerprint FROM omemo_identities WHERE account = :account AND name = :name AND device_id = :deviceId"); static let omemoPreKeyCurrent = Query("SELECT max(id) FROM omemo_pre_keys WHERE account = :account"); static let omemoPreKeyLoad = Query("SELECT key FROM omemo_pre_keys WHERE account = :account AND id = :id"); static let omemoPreKeyInsert = Query("INSERT INTO omemo_pre_keys (account, id, key) VALUES (:account,:id,:key)"); static let omemoPreKeyDelete = Query("DELETE FROM omemo_pre_keys WHERE account = :account AND id = :id"); static let omemoPreKeyWipe = Query("DELETE FROM omemo_pre_keys WHERE account = :account"); static let omemoSignedPreKeyLoad = Query("SELECT key FROM omemo_signed_pre_keys WHERE account = :account AND id = :id"); static let omemoSignedPreKeyInsert = Query("INSERT INTO omemo_signed_pre_keys (account, id, key) VALUES (:account,:id,:key)"); static let omemoSignedPreKeyDelete = Query("DELETE FROM omemo_signed_pre_keys WHERE account = :account AND id = :id"); static let omemoSignedPreKeyCount = Query("SELECT count(1) FROM omemo_signed_pre_keys WHERE account = :account"); static let omemoSignedPreKeyWipe = Query("DELETE FROM omemo_signed_pre_keys WHERE account = :account"); static let omemoSessionRecordLoad = Query("SELECT key FROM omemo_sessions WHERE account = :account AND name = :name AND device_id = :deviceId"); static let omemoSessionRecordInsert = Query("INSERT INTO omemo_sessions (account, name, device_id, key) VALUES (:account, :name, :deviceId, :key)"); static let omemoSessionRecordDelete = Query("DELETE FROM omemo_sessions WHERE account = :account AND name = :name AND device_id = :deviceId"); static let omemoSessionRecordDeleteAll = Query("DELETE FROM omemo_sessions WHERE account = :account AND name = :name"); static let omemoSessionRecordWipe = Query("DELETE FROM omemo_sessions WHERE account = :account"); static let omemoDevicesFind = Query("SELECT device_id FROM omemo_sessions WHERE account = :account AND name = :name"); static let omemoDevicesFindActiveAndTrusted = Query("SELECT s.device_id FROM omemo_sessions s LEFT JOIN omemo_identities i ON s.account = i.account AND s.name = i.name AND s.device_id = i.device_id WHERE s.account = :account AND s.name = :name AND ((i.status >= 0 AND i.status % 2 = 0) OR i.status IS NULL)"); } class DBOMEMOStore { public static let instance = DBOMEMOStore(); func keyPair(forAccount account: BareJID) -> SignalIdentityKeyPairProtocol? { guard let deviceId = localRegistrationId(forAccount: account) else { return nil; } guard let data = try! Database.main.reader({ database in return try database.select(query: .omemoKeyPairForAccount, params: ["account": account, "name": account.stringValue, "deviceId": deviceId]).mapFirst({ $0.data(for: "key") }); }) else { return nil; } return SignalIdentityKeyPair(fromKeyPairData: data); } func identityFingerprint(forAccount account: BareJID, andAddress address: SignalAddress) -> String? { let params: [String: Any?] = ["account": account, "name": address.name, "deviceId": address.deviceId]; return try! Database.main.reader({ database in return try database.select(query: .omemoIdentityFingerprintFind, params: params).mapFirst({ $0.string(for: "fingerprint")}); }) } func identities(forAccount account: BareJID, andName name: String) -> [Identity] { let params: [String: Any?] = ["account": account, "name": name]; return try! Database.main.reader({ database in return try database.select(query: .omemoIdentityFind, params: params).mapAll({ cursor -> Identity? in guard let fingerprint: String = cursor["fingerprint"], let statusInt: Int = cursor["status"], let status = IdentityStatus(rawValue: statusInt), let deviceId: Int32 = cursor["device_id"], let own: Int = cursor["own"], let key: Data = cursor["key"] else { return nil; } return Identity(address: SignalAddress(name: name, deviceId: deviceId), status: status, fingerprint: fingerprint, key: key, own: own > 0); }) }); } func localRegistrationId(forAccount account: BareJID) -> UInt32? { return AccountSettings.omemoRegistrationId(for: account); } func save(identity: SignalAddress, key: SignalIdentityKeyProtocol?, forAccount account: BareJID, own: Bool = false) -> Bool { guard let key = key else { // should we remove this key? return false; } guard let publicKeyData = key.publicKey else { return false; } let fingerprint: String = self.fingerprint(publicKey: publicKeyData); defer { _ = self.setStatus(.verifiedActive, forIdentity: identity, andAccount: account); } return save(identity: identity, fingerprint: fingerprint, own: own, data: key.serialized(), forAccount: account); } func fingerprint(publicKey: Data) -> String { return publicKey.map { (byte) -> String in return String(format: "%02x", byte) }.joined(); } func save(identity: SignalAddress, publicKeyData: Data?, forAccount account: BareJID) -> Bool { guard let publicKeyData = publicKeyData else { // should we remove this key? return false; } let fingerprint: String = self.fingerprint(publicKey: publicKeyData); return save(identity: identity, fingerprint: fingerprint, own: false, data: publicKeyData, forAccount: account); } private func save(identity: SignalAddress, fingerprint: String, own: Bool, data: Data?, forAccount account: BareJID) -> Bool { return try! Database.main.writer({ database -> Bool in let paramsCount: [String: Any?] = ["account": account, "name": identity.name, "fingerprint": fingerprint]; guard try database.count(query: .omemoKeyPairExists, params: paramsCount) == 0 else { return true; } var params: [String: Any?] = paramsCount; params["deviceId"] = identity.deviceId; params["key"] = data; params["own"] = own ? 1 : 0; params["status"] = IdentityStatus.trustedActive.rawValue; try database.insert(query: .omemoKeyPairInsert, params: params); return true; }); } func setStatus(_ status: IdentityStatus, forIdentity identity: SignalAddress, andAccount account: BareJID) -> Bool { return try! Database.main.writer({ database in try database.update(query: .omemoKeyPairUpdateStatus, params: ["account": account, "name": identity.name, "deviceId": identity.deviceId, "status": status.rawValue]); return database.changes; }) > 0; } func setStatus(active: Bool, forIdentity identity: SignalAddress, andAccount account: BareJID) -> Bool { guard let status = try! Database.main.reader({ database in return try database.select(query: .omemoKeyPairLoadStatus, params: ["account": account, "name": identity.name, "deviceId": identity.deviceId]).mapFirst({ cursor in return IdentityStatus(rawValue: cursor.int(for: "status") ?? 0); }); }) else { return false; } return setStatus(active ? status.toActive() : status.toInactive(), forIdentity: identity, andAccount: account); } func currentPreKeyId(forAccount account: BareJID) -> UInt32 { return UInt32(try! Database.main.reader({ database in return try database.select(query: .omemoPreKeyCurrent, params: ["account": account]).mapFirst({ $0.int(at: 0) }) }) ?? 0); } func loadPreKey(forAccount account: BareJID, withId: UInt32) -> Data? { return try! Database.main.reader({ database in try database.select(query: .omemoPreKeyLoad, params: ["account": account, "id": withId]).mapFirst({ $0.data(for: "key") }); }); } func store(preKey: Data, forAccount account: BareJID, withId: UInt32) -> Bool { return try! Database.main.writer({ database in try database.insert(query: .omemoPreKeyInsert, params: ["account": account, "id": withId, "key": preKey]); return database.changes != 0; }) } func containsPreKey(forAccount account: BareJID, withId: UInt32) -> Bool { return loadPreKey(forAccount: account, withId: withId) != nil; } func deletePreKey(forAccount account: BareJID, withId: UInt32) -> Bool { return try! Database.main.writer({ database in try database.delete(query: .omemoPreKeyDelete, cached: false, params: ["account": account, "id": withId]); return database.changes != 0; }) } func countSignedPreKeys(forAccount account: BareJID) -> Int { return try! Database.main.reader({ database in try database.count(query: .omemoSignedPreKeyCount, cached: false, params: ["account": account]); }); } func loadSignedPreKey(forAccount account: BareJID, withId: UInt32) -> Data? { return try! Database.main.reader({ database in return try database.select(query: .omemoSignedPreKeyLoad, params: ["account": account, "id": withId]).mapFirst({ $0.data(for: "key") }); }) } func store(signedPreKey: Data, forAccount account: BareJID, withId: UInt32) -> Bool { return try! Database.main.writer({ database in try database.insert(query: .omemoSignedPreKeyInsert, params: ["account": account, "id": withId, "key": signedPreKey]); return database.changes > 0; }); } func containsSignedPreKey(forAccount account: BareJID, withId: UInt32) -> Bool { return loadPreKey(forAccount: account, withId: withId) != nil; } func deleteSignedPreKey(forAccount account: BareJID, withId: UInt32) -> Bool { return try! Database.main.writer({ database in try database.delete(query: .omemoSignedPreKeyDelete, cached: false, params: ["account": account, "id": withId]); return database.changes > 0; }) } func sessionRecord(forAccount account: BareJID, andAddress address: SignalAddress) -> Data? { return try! Database.main.reader({ database in return try database.select(query: .omemoSessionRecordLoad, params: ["account": account, "name": address.name, "deviceId": address.deviceId]).mapFirst({ $0.data(for: "key") }); }) } func allDevices(forAccount account: BareJID, andName name: String, activeAndTrusted: Bool) -> [Int32] { let params: [String: Any?] = ["account": account, "name": name]; return try! Database.main.reader({ database in return try database.select(query: activeAndTrusted ? .omemoDevicesFindActiveAndTrusted : .omemoDevicesFind, params: params).mapAll({ $0["device_id"] }); }) } func store(sessionRecord: Data, forAccount account: BareJID, andAddress address: SignalAddress) -> Bool { return try! Database.main.writer({ database in try database.insert(query: .omemoSessionRecordInsert, params: ["account": account, "name": address.name, "deviceId": address.deviceId, "key": sessionRecord]); return database.changes > 0; }) } func containsSessionRecord(forAccount account: BareJID, andAddress address: SignalAddress) -> Bool { return sessionRecord(forAccount: account, andAddress: address) != nil; } func deleteSessionRecord(forAccount account: BareJID, andAddress address: SignalAddress) -> Bool { return try! Database.main.writer({ database in try database.delete(query: .omemoSessionRecordDelete, params: ["account": account, "name": address.name, "deviceId": address.deviceId]); return database.changes > 0; }) } func deleteAllSessions(forAccount account: BareJID, andName name: String) -> Bool { return try! Database.main.writer({ database in try database.delete(query: .omemoSessionRecordDeleteAll, params: ["account": account, "name": name]); return database.changes > 0; }); } func wipe(forAccount account: BareJID) { try! Database.main.writer({ database in try database.delete(query: .omemoSessionRecordWipe, params:["account": account]); try database.delete(query: .omemoPreKeyWipe, params: ["account": account]); try database.delete(query: .omemoSignedPreKeyWipe, params: ["account": account]); try database.delete(query: .omemoIdentitiesWipe, cached: false, params: ["account": account]); }) } } class SignalIdentityKeyStore: SignalIdentityKeyStoreProtocol, ContextAware { weak var context: Context?; func keyPair() -> SignalIdentityKeyPairProtocol? { return DBOMEMOStore.instance.keyPair(forAccount: context!.sessionObject.userBareJid!); } func localRegistrationId() -> UInt32 { return DBOMEMOStore.instance.localRegistrationId(forAccount: context!.sessionObject.userBareJid!) ?? 0; } func save(identity: SignalAddress, key: SignalIdentityKeyProtocol?) -> Bool { return DBOMEMOStore.instance.save(identity: identity, key: key, forAccount: context!.sessionObject.userBareJid!, own: true) } func save(identity: SignalAddress, publicKeyData: Data?) -> Bool { return DBOMEMOStore.instance.save(identity: identity, publicKeyData: publicKeyData, forAccount: context!.sessionObject.userBareJid!); } func setStatus(_ status: IdentityStatus, forIdentity: SignalAddress) -> Bool { return DBOMEMOStore.instance.setStatus(status, forIdentity: forIdentity, andAccount: context!.sessionObject.userBareJid!); } func setStatus(active: Bool, forIdentity: SignalAddress) -> Bool { return DBOMEMOStore.instance.setStatus(active: active, forIdentity: forIdentity, andAccount: context!.sessionObject.userBareJid!); } func isTrusted(identity: SignalAddress, key: SignalIdentityKeyProtocol?) -> Bool { return true; } func isTrusted(identity: SignalAddress, publicKeyData: Data?) -> Bool { return true; } func identityFingerprint(forAddress address: SignalAddress) -> String? { return DBOMEMOStore.instance.identityFingerprint(forAccount: self.context!.sessionObject.userBareJid!, andAddress: address); } func identities(forName name: String) -> [Identity] { return DBOMEMOStore.instance.identities(forAccount: self.context!.sessionObject.userBareJid!, andName: name); } } class SignalPreKeyStore: SignalPreKeyStoreProtocol, ContextAware { //fileprivate(set) var currentPreKeyId: UInt32 = 0; weak var context: Context? // { // didSet { // self.currentPreKeyId = AccountSettings.omemoCurrentPreKeyId(context.sessionObject.userBareJid!).uint32() ?? 0; // } // } private let queue = DispatchQueue(label: "SignalPreKeyRemovalQueue"); private var preKeysMarkedForRemoval: [UInt32] = []; func currentPreKeyId() -> UInt32 { return DBOMEMOStore.instance.currentPreKeyId(forAccount: context!.sessionObject.userBareJid!); } func loadPreKey(withId: UInt32) -> Data? { return DBOMEMOStore.instance.loadPreKey(forAccount: context!.sessionObject.userBareJid!, withId: withId); } func storePreKey(_ data: Data, withId: UInt32) -> Bool { guard DBOMEMOStore.instance.store(preKey: data, forAccount: context!.sessionObject.userBareJid!, withId: withId) else { return false; } // AccountSettings.omemoCurrentPreKeyId(context.sessionObject.userBareJid!).set(value: withId); return true; } func containsPreKey(withId: UInt32) -> Bool { return DBOMEMOStore.instance.containsPreKey(forAccount: context!.sessionObject.userBareJid!, withId: withId); } func deletePreKey(withId: UInt32) -> Bool { queue.async { print("queueing prekey with id \(withId) for removal.."); self.preKeysMarkedForRemoval.append(withId); } return true; } func flushDeletedPreKeys() -> Bool { return queue.sync(execute: { () -> [UInt32] in defer { preKeysMarkedForRemoval.removeAll(); } print("removing queued prekeys: \(preKeysMarkedForRemoval)"); return preKeysMarkedForRemoval.filter({ id in DBOMEMOStore.instance.deletePreKey(forAccount: context!.sessionObject.userBareJid!, withId: id) }); }).count > 0; } } class SignalSignedPreKeyStore: SignalSignedPreKeyStoreProtocol, ContextAware { weak var context: Context?; func countSignedPreKeys() -> Int { return DBOMEMOStore.instance.countSignedPreKeys(forAccount: context!.sessionObject.userBareJid!); } func loadSignedPreKey(withId: UInt32) -> Data? { return DBOMEMOStore.instance.loadSignedPreKey(forAccount: context!.sessionObject.userBareJid!, withId: withId); } func storeSignedPreKey(_ data: Data, withId: UInt32) -> Bool { return DBOMEMOStore.instance.store(signedPreKey: data, forAccount: context!.sessionObject.userBareJid!, withId: withId); } func containsSignedPreKey(withId: UInt32) -> Bool { return DBOMEMOStore.instance.containsSignedPreKey(forAccount: context!.sessionObject.userBareJid!, withId: withId); } func deleteSignedPreKey(withId: UInt32) -> Bool { return DBOMEMOStore.instance.deleteSignedPreKey(forAccount: context!.sessionObject.userBareJid!, withId: withId); } } class SignalSessionStore: SignalSessionStoreProtocol, ContextAware { weak var context: Context?; func sessionRecord(forAddress address: SignalAddress) -> Data? { return DBOMEMOStore.instance.sessionRecord(forAccount: context!.sessionObject.userBareJid!, andAddress: address); } func allDevices(for name: String, activeAndTrusted: Bool) -> [Int32] { return DBOMEMOStore.instance.allDevices(forAccount: context!.sessionObject.userBareJid!, andName: name, activeAndTrusted: activeAndTrusted); } func storeSessionRecord(_ data: Data, forAddress address: SignalAddress) -> Bool { return DBOMEMOStore.instance.store(sessionRecord: data, forAccount: context!.sessionObject.userBareJid!, andAddress: address); } func containsSessionRecord(forAddress: SignalAddress) -> Bool { return DBOMEMOStore.instance.containsSessionRecord(forAccount: context!.sessionObject.userBareJid!, andAddress: forAddress); } func deleteSessionRecord(forAddress: SignalAddress) -> Bool { return DBOMEMOStore.instance.deleteSessionRecord(forAccount: context!.sessionObject.userBareJid!, andAddress: forAddress); } func deleteAllSessions(for name: String) -> Bool { return DBOMEMOStore.instance.deleteAllSessions(forAccount: context!.sessionObject.userBareJid!, andName: name); } } class OMEMOStoreWrapper: SignalStorage { fileprivate weak var context: Context!; fileprivate var signalContext: SignalContext?; init(context: Context) { self.context = context; let preKeyStore = SignalPreKeyStore(); preKeyStore.context = context; let signedPreKeyStore = SignalSignedPreKeyStore(); signedPreKeyStore.context = context; let identityKeyStore = SignalIdentityKeyStore(); identityKeyStore.context = context; let sessionStore = SignalSessionStore(); sessionStore.context = context; super.init(sessionStore: sessionStore, preKeyStore: preKeyStore, signedPreKeyStore: signedPreKeyStore, identityKeyStore: identityKeyStore, senderKeyStore: SignalSenderKeyStore()); } override func setup(withContext signalContext: SignalContext) { self.signalContext = signalContext; _ = regenerateKeys(wipe: false); super.setup(withContext: signalContext); } override func regenerateKeys(wipe: Bool = false) -> Bool { guard let signalContext = self.signalContext else { return false; } if wipe { DBOMEMOStore.instance.wipe(forAccount: context!.sessionObject.userBareJid!); } let hasKeyPair = identityKeyStore.keyPair() != nil; if wipe || identityKeyStore.localRegistrationId() == 0 || !hasKeyPair { let regId: UInt32 = signalContext.generateRegistrationId(); AccountSettings.omemoRegistrationId(for: context!.sessionObject.userBareJid!, value: regId); let keyPair = SignalIdentityKeyPair.generateKeyPair(context: signalContext); if !identityKeyStore.save(identity: SignalAddress(name: context!.sessionObject.userBareJid!.stringValue, deviceId: Int32(identityKeyStore.localRegistrationId())), key: keyPair) { } } return true; } } class SignalSenderKeyStore: SignalSenderKeyStoreProtocol { func storeSenderKey(_ key: Data, address: SignalAddress?, groupId: String?) -> Bool { return false; } func loadSenderKey(forAddress address: SignalAddress?, groupId: String?) -> Data? { return nil; } } ================================================ FILE: SiskinIM/database/DBRosterStore.swift ================================================ // // DBRosterStore.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import TigaseSQLite3 extension Query { static let rosterInsertItem = Query("INSERT INTO roster_items (account, jid, name, subscription, timestamp, ask, data) VALUES (:account, :jid, :name, :subscription, :timestamp, :ask, :data)"); static let rosterUpdateItem = Query("UPDATE roster_items SET name = :name, subscription = :subscription, timestamp = :timestamp, ask = :ask, data = :data WHERE id = :id"); static let rosterDeleteItem = Query("DELETE FROM roster_items WHERE id = :id"); static let rosterFindItemsForAccount = Query("SELECT id, jid, name, subscription, ask, data FROM roster_items WHERE account = :account"); } class AccountRoster { private var roster = [JID: RosterItem](); private let queue = DispatchQueue(label: "accountRoster"); init(items: [RosterItem]) { for item in items { roster[item.jid] = item; } } deinit { print("deinitializing AccountRoster"); } public var items: [RosterItem] { return queue.sync { return self.roster.values.map({ $0 }); } } public func item(for jid: JID) -> RosterItem? { return queue.sync { return roster[jid]; } } public func update(item: RosterItem) { queue.async(flags: .barrier) { self.roster[item.jid] = item; } } public func remove(for jid: JID) { queue.async(flags: .barrier) { self.roster.removeValue(forKey: jid); } } } open class DBRosterStore: RosterStore { public typealias RosterItem = Siskin.RosterItem static let instance: DBRosterStore = DBRosterStore.init(); private let queue: DispatchQueue; private var accountRosters = [BareJID: AccountRoster](); @Published public private(set) var items: Set = []; private init() { self.queue = DispatchQueue(label: "db_roster_store"); } public func clear(for account: BareJID) { queue.sync { let items = Set(self.accountRosters[account]?.items ?? []); self.items = self.items.filter({ !items.contains($0) }); for item in items { _remove(for: account, jid: item.jid); } } } public func clear(for context: Context) { self.clear(for: context.userBareJid); } func items(for account: BareJID) -> [RosterItem] { return queue.sync { return self.accountRosters[account]; }?.items ?? []; } public func items(for context: Context) -> [RosterItem] { return items(for: context.userBareJid); } func item(for account: BareJID, jid: JID) -> RosterItem? { return queue.sync { return self.accountRosters[account]; }?.item(for: jid); } public func item(for context: Context, jid: JID) -> RosterItem? { return item(for: context.userBareJid, jid: jid); } public func updateItem(for context: Context, jid: JID, name: String?, subscription: RosterItemSubscription, groups: [String], ask: Bool, annotations: [RosterItemAnnotation]) { let account = context.userBareJid; let data = DBRosterData(groups: groups, annotations: annotations); queue.sync { guard let item = self.accountRosters[account]?.item(for: jid) else { let params: [String: Any?] = ["account": account, "jid": jid, "name": name, "subscription": subscription.rawValue, "timestamp": Date(), "ask": ask, "data": data]; let id = try! Database.main.writer({ database -> Int? in try database.insert(query: .rosterInsertItem, params: params); return database.lastInsertedRowId })!; let item = RosterItem(id: id, context: context, jid: jid, name: name, subscription: subscription, groups: groups, ask: ask, annotations: annotations); self.accountRosters[account]?.update(item: item); self.items.insert(item); itemUpdated(item, context: context); return; } let params: [String: Any?] = ["id": item.id, "name": name, "subscription": subscription.rawValue, "timestamp": Date(), "ask": ask, "data": data]; try! Database.main.writer({ database in try database.update(query: .rosterUpdateItem, params: params); }) let newItem = RosterItem(id: item.id, context: context, jid: jid, name: name, subscription: subscription, groups: groups, ask: ask, annotations: annotations); self.accountRosters[account]?.update(item: newItem); var newItems = self.items; newItems.remove(item); newItems.insert(newItem); self.items = newItems; itemUpdated(newItem, context: context); } } func remove(for account: BareJID, jid: JID) { queue.sync { _remove(for: account, jid: jid); } } private func _remove(for account: BareJID, jid: JID) { guard let accountRoster = self.accountRosters[account] else { return; } if let item = accountRoster.item(for: jid) { accountRoster.remove(for: jid); try! Database.main.writer({ database in try database.delete(query: .rosterDeleteItem, params: ["id": item.id]); }) self.items.remove(item); } } func itemUpdated(_ newItem: RosterItem, context: Context) { ContactManager.instance.update(name: newItem.name, for: .init(account: context.userBareJid, jid: newItem.jid.bareJid, type: .buddy)) DBChatStore.instance.refreshConversationsList(); } public func deleteItem(for context: Context, jid: JID) { self.remove(for: context.userBareJid, jid: jid); DBChatStore.instance.refreshConversationsList(); } public func version(for context: Context) -> String? { return nil; } public func set(version: String?, for context: Context) { // not implemented } public func initialize(context: Context) { return queue.async { guard self.accountRosters[context.userBareJid] == nil else { return; } let items = try! Database.main.reader({ database in try database.select(query: .rosterFindItemsForAccount, params: ["account": context.userBareJid]).mapAll({ RosterItem.from(cursor: $0, context: context) }) }); self.accountRosters[context.userBareJid] = AccountRoster(items: items); self.items = Set(self.items + items); } } public func deinitialize(context: Context) { queue.async { guard let roster = self.accountRosters[context.userBareJid] else { return; } self.accountRosters.removeValue(forKey: context.userBareJid); let items = Set(roster.items); self.items = self.items.filter({ !items.contains($0) }); } } } struct DBRosterData: Codable, DatabaseConvertibleStringValue { let groups: [String]; let annotations: [RosterItemAnnotation]; } public class RosterItem: Martin.RosterItemBase, Identifiable, Hashable { public static func == (lhs: RosterItem, rhs: RosterItem) -> Bool { return lhs.id == rhs.id; } static func from(cursor: Cursor, context: Context) -> RosterItem? { let itemId: Int = cursor.int(for: "id")!; let jid: JID = cursor.jid(for: "jid")!; let name: String? = cursor.string(for: "name"); let subscription = RosterItemSubscription(rawValue: cursor.string(for: "subscription")!)!; let ask: Bool = cursor.bool(for: "ask"); let data: DBRosterData = cursor.object(for: "data") ?? DBRosterData(groups: [], annotations: []); return RosterItem(id: itemId, context: context, jid: jid, name: name, subscription: subscription, groups: data.groups, ask: ask, annotations: data.annotations); } public let id: Int; public private(set) weak var context: Context?; public init(id: Int, context: Context, jid: JID, name: String?, subscription: RosterItemSubscription, groups: [String], ask: Bool, annotations: [RosterItemAnnotation]) { self.id = id; self.context = context; super.init(jid: jid, name: name, subscription: subscription, groups: groups, ask: ask, annotations: annotations); } public func hash(into hasher: inout Hasher) { hasher.combine(id); } } ================================================ FILE: SiskinIM/database/DBVCardStore.swift ================================================ // // DBVCardStore.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import TigaseSQLite3 extension Query { static let vcardInsert = Query("INSERT INTO vcards_cache (jid, data, timestamp) VALUES (:jid,:data,:timestamp)"); static let vcardUpdate = Query("UPDATE vcards_cache SET data = :data, timestamp = :timestamp WHERE jid = :jid"); static let vcardFindByJid = Query("SELECT data FROM vcards_cache WHERE jid = :jid"); } class DBVCardStore { public static let VCARD_UPDATED = Notification.Name("vcardUpdated"); public static let instance = DBVCardStore(); private let dispatcher = QueueDispatcher(label: "vcard_store"); private init() { } open func vcard(for jid: BareJID, completionHandler: @escaping (VCard?)->Void) { dispatcher.async { let data: String? = try! Database.main.reader({ database in try database.select(query: .vcardFindByJid, params: ["jid": jid]).mapFirst({ cursor -> String? in return cursor.string(for: "data"); }) }); guard let value = data, let elem = Element.from(string: value) else { completionHandler(nil); return; } completionHandler(VCard(vcard4: elem) ?? VCard(vcardTemp: elem)); } } open func updateVCard(for jid: BareJID, on account: BareJID, vcard: VCard) { dispatcher.async { try! Database.main.writer({ database in let params: [String: Any?] = ["jid": jid, "data": vcard.toVCard4(), "timestamp": Date()]; try database.update(query: .vcardUpdate, params: params); if database.changes == 0 { try database.insert(query: .vcardInsert, params: params); } }) NotificationCenter.default.post(name: DBVCardStore.VCARD_UPDATED, object: VCardItem(vcard: vcard, for: jid, on: account)); } } class VCardItem { let vcard: VCard; let account: BareJID; let jid: BareJID; init(vcard: VCard, for jid: BareJID, on account: BareJID) { self.vcard = vcard; self.jid = jid; self.account = account; } } } ================================================ FILE: SiskinIM/database/Database.swift ================================================ // // Database.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import TigaseSQLite3 import Martin import TigaseLogging extension Database { static let main: DatabasePool = { return try! DatabasePool(dbUrl: mainDatabaseUrl(), schemaMigrator: DatabaseMigrator()); }(); } extension DatabasePool { private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "sqlite"); convenience init(dbUrl: URL, schemaMigrator: DatabaseSchemaMigrator? = nil) throws { try self.init(configuration: Configuration(path: dbUrl.path, schemaMigrator: schemaMigrator)); DatabasePool.logger.info("Initialized database: \(dbUrl.path)"); } } ================================================ FILE: SiskinIM/database/DatabaseMigrator.swift ================================================ // // DatabaseMigrator.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import TigaseSQLite3 import TigaseLogging import CoreLocation public class DatabaseMigrator: DatabaseSchemaMigrator { public let expectedVersion: Int = 18; private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DatabaseMigrator"); public func upgrade(database: DatabaseWriter, newVersion version: Int) throws { try loadSchema(to: database, fromFile: "/db-schema-\(version).sql"); if version == 14 { try cleanupDuplicatedEntries(database: database); do { try database.execute("select error from chat_history"); } catch { try database.execute("ALTER TABLE chat_history ADD COLUMN error TEXT;"); } } switch version { case 14: try database.execute("ALTER TABLE roster_items ADD COLUMN data TEXT"); let groupMapping = try database.select("SELECT rig.item_id as item_id, rg.name as name FROM roster_items ri INNER JOIN roster_items_groups rig ON ri.id = rig.item_id INNER JOIN roster_groups rg ON rig.group_id = rg.id", cached: false).mapAll({ cursor -> (Int, String)? in return (cursor.int(for: "item_id")!, cursor.string(for: "name")!); }); try Set(groupMapping.map { $0.0 }).forEach({ itemId in let groups = groupMapping.filter({ $0.0 == itemId }).map({ $0.1 }); let annnotations: [RosterItemAnnotation] = try database.select("SELECT annotations FROM roster_items ri WHERE ri.id = :id", cached: false, params: ["id": itemId]).mapFirst({ $0.object(at: 0) }) ?? []; let data = DBRosterData(groups: groups, annotations: annnotations); try database.update("UPDATE roster_items SET data = :data WHERE id = :id", cached: false, params: ["data": data, "id": itemId]); }) let roomsToUpdate: [(Int,RoomOptions)] = try database.select("SELECT c.id, c.name, c.nickname, c.password FROM chats c WHERE c.type = 1 AND c.nickname IS NOT NULL", cached: false).mapAll({ c -> (Int,RoomOptions)? in guard let id = c.int(at: 0), let nickname = c.string(at: 2) else { return nil; } let password = c.string(at: 3); let name = c.string(at: 1); var options: RoomOptions = c.object(for: "options") ?? RoomOptions(); if options.nickname.isEmpty { let notifications = options.notifications; options = RoomOptions(nickname: nickname, password: password); options.notifications = notifications; options.name = name; } return (id, options); }) for (id, options) in roomsToUpdate { try database.update("update chats set name = null, nickname = null, password = null, options = :options where id = :id", cached: false, params: ["id": id, "options": options]) } case 15: try database.execute("ALTER TABLE chat_history ADD COLUMN markable INTEGER NOT NULL DEFAULT 0"); try database.executeQueries(""" ALTER TABLE chat_history_sync RENAME TO chat_history_sync_old; CREATE TABLE IF NOT EXISTS chat_history_sync ( id TEXT NOT NULL COLLATE NOCASE, account TEXT NOT NULL COLLATE NOCASE, component TEXT COLLATE NOCASE, from_timestamp INTEGER NOT NULL, from_id TEXT, to_timestamp INTEGER ); INSERT INTO chat_history_sync (id, account, component, from_timestamp, from_id, to_timestamp) SELECT id, account, component, from_timestamp, from_id, to_timestamp FROM chat_history_sync_old; DROP TABLE chat_history_sync_old; """); case 16: let itemsToUpdate = try database.select("select id, data from chat_history where item_type = \(ItemType.message.rawValue) and data like 'geo:%,%' ", cached: false, params: [:]).mapAll({ ($0.int(for: "id"), $0.string(for: "data")) }).compactMap({ item -> Int? in guard let data = item.1, CLLocationCoordinate2D(geoUri: data) != nil else { return nil; } return item.0; }); for id in itemsToUpdate { try database.update("update chat_history set item_type = \(ItemType.location.rawValue) where id = :id", cached: false, params: ["id": id]); } case 17: let idsToRemove = try database.select("select c1.id from chats c1 join (select account, jid, max(timestamp) as timestamp, count(id) from chats group by account, jid having count(id) > 1) c2 on c1.account = c2.account and c1.jid = c2.jid and c1.timestamp < c2.timestamp", cached: false).mapAll({ $0.int(for: "id") }); for id in idsToRemove { try database.delete("delete from chats where id = :id", cached: false, params: ["id": id]); } case 18: try database.executeQueries(""" CREATE INDEX chat_history_account_jid_stanza_id on chat_history (account, jid, stanza_id); CREATE INDEX chat_history_account_jid_correction_stanza_id on chat_history (account, jid, correction_stanza_id); """); default: break; } } private func loadSchema(to database: DatabaseWriter, fromFile fileName: String) throws { let resourcePath = Bundle.main.resourcePath! + fileName; logger.debug("trying to load SQL from file \(resourcePath)"); if let dbSchema = try? String(contentsOfFile: resourcePath, encoding: String.Encoding.utf8) { logger.debug("read schema: \(dbSchema)"); try database.executeQueries(dbSchema); logger.debug("loaded schema from file \(fileName)"); } else { logger.debug("skipped loading schema from file"); } } // Method used to cleanup schema before version no. 12 private func cleanupDuplicatedEntries(database: DatabaseWriter) throws { // removing duplicaed chats let duplicatedChats = try database.select("select account, jid, count(id) from chats group by account, jid", cached: false).mapAll({ cursor -> (BareJID, BareJID)? in guard cursor.int(at: 2)! > 1 else { return nil; } return (cursor.bareJid(at: 0)!, cursor.bareJid(at: 2)!); }) for pair in duplicatedChats { try database.delete("delete from chats where account = :account and jid = :jid", cached: false, params: ["account": pair.0, "jid": pair.1]); } // remove omemo session without identities let omemoSessionsWithoutIdentity = try database.select("SELECT sess.account as account, sess.name as name, sess.device_id as deviceId FROM omemo_sessions sess WHERE NOT EXISTS (select 1 FROM omemo_identities i WHERE i.account = sess.account and i.name = sess.name and i.device_id = sess.device_id)", cached: false).mapAll({ cursor -> (BareJID, BareJID, Int32)? in return (cursor.bareJid(for: "account")!, cursor.bareJid(for: "name")!, cursor["deviceId"]!); }) for triple in omemoSessionsWithoutIdentity { try database.delete("DELETE FROM omemo_sessions WHERE account = :account AND name = :name AND device_id = :deviceId", cached: false, params: ["account": triple.0, "name": triple.1, "deviceId": triple.2]); } // convert chat encryption from separate field to options let chatsToConvertEncryption = try database.select("SELECT account, jid, encryption FROM chats WHERE encryption IS NOT NULL AND options IS NULL", cached: false).mapAll({ cursor -> (BareJID, BareJID, ChatEncryption)? in guard let encryptionStr: String = cursor["encryption"] else { return nil; } guard let encryption = ChatEncryption(rawValue: encryptionStr) else { return nil; } return (cursor["account"]!, cursor["jid"]!, encryption); }); for triple in chatsToConvertEncryption { var options = ChatOptions(); options.encryption = triple.2; try database.update("UPDATE chats SET options = ?, encryption = null WHERE account = ? AND jid = ?", cached: false, params: [options, triple.0, triple.1]); } } } ================================================ FILE: SiskinIM/database/MessageState.swift ================================================ // // MessageState.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation public enum MessageState: Int { // x % 2 == 0 - incoming // x % 2 == 1 - outgoing case incoming = 0 case outgoing = 1 case incoming_unread = 2 case outgoing_unsent = 3 case incoming_error = 4 case outgoing_error = 5 case incoming_error_unread = 6 case outgoing_error_unread = 7 case outgoing_delivered = 9 case outgoing_read = 11 var direction: MessageDirection { switch self { case .incoming, .incoming_unread, .incoming_error, .incoming_error_unread: return .incoming; case .outgoing, .outgoing_unsent, .outgoing_delivered, .outgoing_read, .outgoing_error_unread, .outgoing_error: return .outgoing; } } var isError: Bool { switch self { case .incoming_error, .incoming_error_unread, .outgoing_error, .outgoing_error_unread: return true; default: return false; } } var isUnread: Bool { switch self { case .incoming_unread, .incoming_error_unread, .outgoing_error_unread: return true; default: return false; } } } public enum MessageDirection: Int { case incoming = 0 case outgoing = 1 } ================================================ FILE: SiskinIM/database/model/DisplayableIdProtocol.swift ================================================ // // DisplayableIdProtocol.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // // import Foundation import Martin import UIKit import Combine public protocol DisplayableIdProtocol { var displayName: String { get } var displayNamePublisher: Published.Publisher { get } var status: Presence.Show? { get } var statusPublisher: Published.Publisher { get } var avatarPublisher: AnyPublisher { get } var description: String? { get } var descriptionPublisher: Published.Publisher { get } } public protocol DisplayableIdWithKeyProtocol: DisplayableIdProtocol { var account: BareJID { get } var jid: BareJID { get } } ================================================ FILE: SiskinIM/database/model/conversations/AccountConversations.swift ================================================ // // AccountConversations.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin public class AccountConversations { private var conversations = [BareJID: Conversation](); private let queue = DispatchQueue(label: "accountChats"); var count: Int { return self.queue.sync(execute: { return self.conversations.count; }) } var items: [Conversation] { return self.queue.sync(execute: { return self.conversations.values.map({ (chat) -> Conversation in return chat; }); }); } init(items: [Conversation]) { items.forEach { item in self.conversations[item.jid] = item; } } func open(with jid: BareJID, execute: () -> Conversation) -> Conversation? { return self.queue.sync(execute: { var chats = self.conversations; guard let existingChat = chats[jid] else { let conversation = execute(); chats[jid] = conversation; self.conversations = chats; return conversation; } return existingChat; }); } func close(conversation: Conversation, execute: ()->Void) -> Bool { return self.queue.sync(execute: { var chats = self.conversations; let removed = chats.removeValue(forKey: conversation.jid) != nil; self.conversations = chats; if removed { execute(); } return removed; }); } func get(with jid: BareJID) -> Conversation? { return self.queue.sync(execute: { let chats = self.conversations; return chats[jid]; }); } func lastMessageTimestamp() -> Date { return self.queue.sync(execute: { var timestamp = Date(timeIntervalSince1970: 0); self.conversations.values.forEach { (chat) in guard chat.lastActivity != nil else { return; } timestamp = max(timestamp, chat.timestamp); } return timestamp; }); } } ================================================ FILE: SiskinIM/database/model/conversations/Channel.swift ================================================ // // Channel.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import UIKit import Combine import Shared import Intents public class Channel: ConversationBaseWithOptions, ChannelProtocol, Conversation, LastMessageTimestampAware { open override var defaultMessageType: StanzaType { return .groupchat; } @Published open private(set) var permissions: Set?; public var permissionsPublisher: AnyPublisher,Never> { if permissions == nil { context?.module(.mix).retrieveAffiliations(for: self, completionHandler: nil); } return $permissions.compactMap({ $0 }).eraseToAnyPublisher(); } private let participantsStore: MixParticipantsProtocol = MixParticipantsBase(); public func update(state: ChannelState) { updateOptions({ options in options.state = state; }); } public func update(permissions: Set) { dispatcher.async(flags: .barrier) { self.permissions = permissions; } } public func update(info: ChannelInfo) { updateOptions({ options in options.name = info.name; options.description = info.description; }) } public func update(ownNickname nickname: String?) { updateOptions({ options in options.nick = nickname; }) } public var name: String? { return options.name; } private let displayable: ChannelDisplayableId; public var participantId: String { return options.participantId; } public var automaticallyFetchPreviews: Bool { return true; } public var channelJid: BareJID { return jid; } public var nickname: String? { return options.nick; } public var state: ChannelState { return options.state; } private let creationTimestamp: Date; public var lastMessageTimestamp: Date? { guard creationTimestamp == timestamp else { return nil; } return timestamp; } private var connectionState: XMPPClient.State = .disconnected() { didSet { DispatchQueue.main.async { self.updateState(); } } } private var cancellables: Set = []; public var debugDescription: String { return "Channel(account: \(account), jid: \(jid))"; } public enum Feature: String, Codable { case avatar = "avatar" case membersOnly = "members-only" public static func from(node nodeStr: String?) -> Feature? { guard let node = nodeStr else { return nil; } switch node { case "urn:xmpp:mix:nodes:allowed": return .membersOnly; case "urn:xmpp:avatar:metadata": return .avatar; default: return nil; } } public init(from decoder: Decoder) throws { self = Feature(rawValue: try decoder.singleValueContainer().decode(String.self))! } public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer(); try container.encode(self.rawValue); } } init(dispatcher: QueueDispatcher, context: Context, channelJid: BareJID, id: Int, timestamp: Date, lastActivity: LastChatActivity?, unread: Int, options: ChannelOptions, creationTimestamp: Date) { self.creationTimestamp = creationTimestamp; self.displayable = ChannelDisplayableId(displayName: options.name ?? channelJid.stringValue, status: nil, avatar: AvatarManager.instance.avatarPublisher(for: .init(account: context.userBareJid, jid: channelJid, mucNickname: nil)), description: options.description); super.init(dispatcher: dispatcher, context: context, jid: channelJid, id: id, timestamp: timestamp, lastActivity: lastActivity, unread: unread, options: options, displayableId: displayable); context.$state.sink(receiveValue: { [weak self] state in self?.connectionState = state; }).store(in: &cancellables); (context.module(.httpFileUpload) as! HttpFileUploadModule).isAvailablePublisher.combineLatest(context.$state, { isAvailable, state -> [ConversationFeature] in if case .connected(_) = state { return isAvailable ? [.httpFileUpload] : []; } else { return []; } }).sink(receiveValue: { [weak self] value in self?.update(features: value); }).store(in: &cancellables); } public override func isLocal(sender: ConversationEntrySender) -> Bool { switch sender { case .participant(let id, _, let jid): guard let jid = jid else { return participantId == id; } return jid == account; default: return false; } } public override func updateOptions(_ fn: @escaping (inout ChannelOptions) -> Void, completionHandler: (()->Void)? = nil) { super.updateOptions(fn, completionHandler: completionHandler); DispatchQueue.main.async { self.displayable.displayName = self.options.name ?? self.jid.stringValue; self.displayable.description = self.options.description; self.updateState(); } } public override func createMessage(text: String, id: String, type: StanzaType) -> Message { let msg = super.createMessage(text: text, id: id, type: type); msg.isMarkable = true; return msg; } public func sendMessage(text: String, correctedMessageOriginId: String?) { let message = self.createMessage(text: text); message.lastMessageCorrectionId = correctedMessageOriginId; self.send(message: message, completionHandler: nil); if #available(iOS 15.0, *) { let sender = INPerson(personHandle: INPersonHandle(value: self.account.stringValue, type: .unknown), nameComponents: nil, displayName: self.nickname, image: AvatarManager.instance.avatar(for: self.account, on: self.account)?.inImage(), contactIdentifier: nil, customIdentifier: self.account.stringValue, isMe: true, suggestionType: .instantMessageAddress); let recipient = INPerson(personHandle: INPersonHandle(value: self.jid.stringValue, type: .unknown), nameComponents: nil, displayName: self.displayName, image: AvatarManager.instance.avatar(for: self.jid, on: self.account)?.inImage(), contactIdentifier: nil, customIdentifier: self.jid.stringValue, isMe: false, suggestionType: .instantMessageAddress); let intent = INSendMessageIntent(recipients: [recipient], outgoingMessageType: .outgoingMessageText, content: nil, speakableGroupName: INSpeakableString(spokenPhrase: self.displayName), conversationIdentifier: "account=\(self.account.stringValue)|sender=\(self.jid.stringValue)", serviceName: "Siskin IM", sender: sender, attachments: nil); let interaction = INInteraction(intent: intent, response: nil); interaction.direction = .outgoing; interaction.donate(completion: nil); } } public func prepareAttachment(url originalURL: URL, completionHandler: (Result<(URL, Bool, ((URL) -> URL)?), ShareError>) -> Void) { completionHandler(.success((originalURL, false, nil))); } public func sendAttachment(url uploadedUrl: String, appendix: ChatAttachmentAppendix, originalUrl: URL?, completionHandler: (() -> Void)?) { guard ((self.context as? XMPPClient)?.state ?? .disconnected()) == .connected(), self.state == .joined else { completionHandler?(); return; } let message = self.createMessage(text: uploadedUrl); message.oob = uploadedUrl; send(message: message, completionHandler: nil) if #available(iOS 15.0, *) { let sender = INPerson(personHandle: INPersonHandle(value: self.account.stringValue, type: .unknown), nameComponents: nil, displayName: self.nickname, image: AvatarManager.instance.avatar(for: self.account, on: self.account)?.inImage(), contactIdentifier: nil, customIdentifier: self.account.stringValue, isMe: true, suggestionType: .instantMessageAddress); let recipient = INPerson(personHandle: INPersonHandle(value: self.jid.stringValue, type: .unknown), nameComponents: nil, displayName: self.displayName, image: AvatarManager.instance.avatar(for: self.jid, on: self.account)?.inImage(), contactIdentifier: nil, customIdentifier: self.jid.stringValue, isMe: false, suggestionType: .instantMessageAddress); let intent = INSendMessageIntent(recipients: [recipient], outgoingMessageType: .outgoingMessageText, content: nil, speakableGroupName: INSpeakableString(spokenPhrase: self.displayName), conversationIdentifier: "account=\(self.account.stringValue)|sender=\(self.jid.stringValue)", serviceName: "Siskin IM", sender: sender, attachments: nil); let interaction = INInteraction(intent: intent, response: nil); interaction.direction = .outgoing; interaction.donate(completion: nil); } completionHandler?(); } public func canSendChatMarker() -> Bool { return self.options.features.contains(.membersOnly); } public func sendChatMarker(_ marker: Message.ChatMarkers, andDeliveryReceipt receipt: Bool) { guard Settings.confirmMessages else { return; } if options.confirmMessages && canSendChatMarker() { let message = self.createMessage(); message.chatMarkers = marker; message.hints = [.store] if receipt { message.messageDelivery = .received(id: marker.id) } self.send(message: message, completionHandler: nil); } else if case .displayed(_) = marker { let message = createMessage(id: UUID().uuidString, type: .chat); message.to = JID(BareJID(localPart: "\(participantId)#\(jid.localPart!)", domain: jid.domain), resource: nil); message.chatMarkers = marker; message.hints = [.store] self.send(message: message, completionHandler: nil); } } private func updateState() { switch self.options.state { case .left: return self.displayable.status = nil; case .joined: switch self.connectionState { case .connected: self.displayable.status = .online; default: self.displayable.status = nil; } } } private class ChannelDisplayableId: DisplayableIdProtocol { @Published var displayName: String var displayNamePublisher: Published.Publisher { return $displayName; } @Published var status: Presence.Show? var statusPublisher: Published.Publisher { return $status; } @Published var description: String?; var descriptionPublisher: Published.Publisher { return $description; } let avatar: Avatar; var avatarPublisher: AnyPublisher { return avatar.avatarPublisher.replaceNil(with: AvatarManager.instance.defaultGroupchatAvatar).eraseToAnyPublisher(); } init(displayName: String, status: Presence.Show?, avatar: Avatar, description: String?) { self.displayName = displayName; self.status = status; self.description = description; self.avatar = avatar; } } } extension Channel: MixParticipantsProtocol { public var participants: [MixParticipant] { return dispatcher.sync { return self.participantsStore.participants; } } public var participantsPublisher: AnyPublisher<[MixParticipant],Never> { return self.participantsStore.participantsPublisher; } public func participant(withId: String) -> MixParticipant? { return dispatcher.sync { return self.participantsStore.participant(withId: withId); } } public func set(participants: [MixParticipant]) { dispatcher.async(flags: .barrier) { self.participantsStore.set(participants: participants); } } public func update(participant: MixParticipant) { dispatcher.async(flags: .barrier) { self.participantsStore.update(participant: participant); } } public func removeParticipant(withId id: String) -> MixParticipant? { return dispatcher.sync(flags: .barrier) { return self.participantsStore.removeParticipant(withId: id); } } } public struct ChannelOptions: Codable, ChatOptionsProtocol, Equatable { var participantId: String; var nick: String?; var name: String?; var description: String?; var state: ChannelState; public var notifications: ConversationNotification = .always; var features: Set = []; public var confirmMessages: Bool = true; public init(participantId: String, nick: String?, state: ChannelState) { self.participantId = participantId; self.nick = nick; self.state = state; } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self); participantId = try container.decode(String.self, forKey: .participantId); state = try container.decodeIfPresent(Int.self, forKey: .state).map({ ChannelState(rawValue: $0) ?? .joined }) ?? .joined; nick = try container.decodeIfPresent(String.self, forKey: .nick); name = try container.decodeIfPresent(String.self, forKey: .name); description = try container.decodeIfPresent(String.self, forKey: .description); notifications = ConversationNotification(rawValue: try container.decodeIfPresent(String.self, forKey: .notifications) ?? "") ?? .always; features = try container.decodeIfPresent(Set.self, forKey: .features) ?? []; confirmMessages = try container.decodeIfPresent(Bool.self, forKey: .confirmMessages) ?? true; } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self); try container.encode(participantId, forKey: .participantId); try container.encode(state.rawValue, forKey: .state); try container.encodeIfPresent(nick, forKey: .nick); try container.encodeIfPresent(name, forKey: .name); try container.encodeIfPresent(description, forKey: .description); if notifications != .always { try container.encode(notifications.rawValue, forKey: .notifications); } try container.encode(features, forKey: .features); try container.encode(confirmMessages, forKey: .confirmMessages); } public func equals(_ options: ChatOptionsProtocol) -> Bool { guard let options = options as? ChannelOptions else { return false; } return options == self; } enum CodingKeys: String, CodingKey { case participantId = "participantId" case nick = "nick"; case state = "state" case notifications = "notifications"; case name = "name"; case description = "desc"; case features = "features"; case confirmMessages = "confirmMessages"; } } extension MixParticipant: Hashable { public static func == (lhs: MixParticipant, rhs: MixParticipant) -> Bool { return lhs.id == rhs.id; } public func hash(into hasher: inout Hasher) { return hasher.combine(id); } } ================================================ FILE: SiskinIM/database/model/conversations/Chat.swift ================================================ // // Chat.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import MartinOMEMO import UIKit import Combine import Shared import Intents public class Chat: ConversationBaseWithOptions, ChatProtocol, Conversation { public override var defaultMessageType: StanzaType { return .chat; } var localChatState: ChatState = .active; @Published private(set) var remoteChatState: ChatState? = nil; public var automaticallyFetchPreviews: Bool { return DBRosterStore.instance.item(for: account, jid: JID(jid)) != nil; } private var cancellables: Set = []; public var debugDescription: String { return "Chat(account: \(account), jid: \(jid))"; } init(dispatcher: QueueDispatcher, context: Context, jid: BareJID, id: Int, timestamp: Date, lastActivity: LastConversationActivity?, unread: Int, options: ChatOptions) { let contact = ContactManager.instance.contact(for: .init(account: context.userBareJid, jid: jid, type: .buddy)); super.init(dispatcher: dispatcher, context: context, jid: jid, id: id, timestamp: timestamp, lastActivity: lastActivity, unread: unread, options: options, displayableId: contact); (context.module(.httpFileUpload) as! HttpFileUploadModule).isAvailablePublisher.combineLatest(context.$state, { isAvailable, state -> [ConversationFeature] in if case .connected(_) = state { return isAvailable ? [.httpFileUpload, .omemo] : [.omemo]; } else { return [.omemo]; } }).sink(receiveValue: { [weak self] value in self?.update(features: value); }).store(in: &cancellables); } public func isLocalParticipant(jid: JID) -> Bool { return account == jid.bareJid; } func changeChatState(state: ChatState) -> Message? { guard localChatState != state else { return nil; } self.localChatState = state; if (remoteChatState != nil) { let msg = Message(); msg.to = JID(jid); msg.type = StanzaType.chat; msg.chatState = state; return msg; } return nil; } private var remoteChatStateTimer: Foundation.Timer?; // func updateDisplayName(rosterItem: RosterItem?) { // DispatchQueue.main.async { // self.displayName = rosterItem?.name ?? self.jid.stringValue; // } // } func update(remoteChatState state: ChatState?) { // proper handle when we have the same state!! let prevState = remoteChatState; if prevState == .composing { remoteChatStateTimer?.invalidate(); remoteChatStateTimer = nil; } self.remoteChatState = state; if state == .composing { DispatchQueue.main.async { self.remoteChatStateTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 60.0, repeats: false, block: { [weak self] timer in guard let that = self else { return; } if that.remoteChatState == .composing { that.remoteChatState = .active; that.remoteChatStateTimer = nil; } }); } } } public override func createMessage(text: String, id: String, type: StanzaType) -> Message { let msg = super.createMessage(text: text, id: id, type: type); msg.chatState = .active; msg.isMarkable = true; msg.messageDelivery = .request; self.localChatState = .active; return msg; } public func canSendChatMarker() -> Bool { return true; } public func sendChatMarker(_ marker: Message.ChatMarkers, andDeliveryReceipt receipt: Bool) { guard Settings.confirmMessages && options.confirmMessages else { return; } let message = self.createMessage(); message.chatMarkers = marker; if receipt { message.messageDelivery = .received(id: marker.id) } message.hints = [.store]; self.send(message: message, completionHandler: nil); } public func prepareAttachment(url originalURL: URL, completionHandler: @escaping (Result<(URL, Bool, ((URL) -> URL)?), ShareError>) -> Void) { let encryption = self.options.encryption ?? .none; switch encryption { case .none: completionHandler(.success((originalURL, false, nil))); case .omemo: guard let omemoModule: OMEMOModule = self.context?.module(.omemo), let data = try? Data(contentsOf: originalURL) else { completionHandler(.failure(.unknownError)); return; } let result = omemoModule.encryptFile(data: data); switch result { case .success(let (encryptedData, hash)): let tmpFile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString); do { try encryptedData.write(to: tmpFile); completionHandler(.success((tmpFile, true, { url in var parts = URLComponents(url: url, resolvingAgainstBaseURL: true)!; parts.scheme = "aesgcm"; parts.fragment = hash; let shareUrl = parts.url!; return shareUrl; }))); } catch { completionHandler(.failure(.noAccessError)); } case .failure(_): completionHandler(.failure(.unknownError)); } } } public func sendMessage(text: String, correctedMessageOriginId: String?) { let stanzaId = UUID().uuidString; let encryption = self.options.encryption ?? Settings.messageEncryption; if let correctedMessageId = correctedMessageOriginId { DBChatHistoryStore.instance.correctMessage(for: self, stanzaId: correctedMessageId, sender: .none, data: text, correctionStanzaId: stanzaId, correctionTimestamp: Date(), newState: .outgoing(.unsent)); } else { var messageEncryption: ConversationEntryEncryption = .none; switch encryption { case .omemo: messageEncryption = .decrypted(fingerprint: DBOMEMOStore.instance.identityFingerprint(forAccount: self.account, andAddress: SignalAddress(name: self.account.stringValue, deviceId: Int32(bitPattern: DBOMEMOStore.instance.localRegistrationId(forAccount: self.account)!)))); case .none: break; } let options = ConversationEntry.Options(recipient: .none, encryption: messageEncryption, isMarkable: true) DBChatHistoryStore.instance.appendItem(for: self, state: .outgoing(.unsent), sender: .me(conversation: self), type: .message, timestamp: Date(), stanzaId: stanzaId, serverMsgId: nil, remoteMsgId: nil, data: text, appendix: nil, options: options, linkPreviewAction: .none, completionHandler: nil); } resendMessage(content: text, isAttachment: false, encryption: encryption, stanzaId: stanzaId, correctedMessageOriginId: correctedMessageOriginId); } // we are only encrypting URL and not file content, it should be encoded prior uploading public func sendAttachment(url: String, appendix: ChatAttachmentAppendix, originalUrl: URL?, completionHandler: (()->Void)?) { let stanzaId = UUID().uuidString; let encryption = self.options.encryption ?? Settings.messageEncryption; var messageEncryption: ConversationEntryEncryption = .none; switch encryption { case .omemo: messageEncryption = .decrypted(fingerprint: DBOMEMOStore.instance.identityFingerprint(forAccount: self.account, andAddress: SignalAddress(name: self.account.stringValue, deviceId: Int32(bitPattern: DBOMEMOStore.instance.localRegistrationId(forAccount: self.account)!)))); case .none: break; } let options = ConversationEntry.Options(recipient: .none, encryption: messageEncryption, isMarkable: true) DBChatHistoryStore.instance.appendItem(for: self, state: .outgoing(.unsent), sender: .me(conversation: self), type: .attachment, timestamp: Date(), stanzaId: stanzaId, serverMsgId: nil, remoteMsgId: nil, data: url, appendix: appendix, options: options, linkPreviewAction: .none, completionHandler: { msgId in if let url = originalUrl { _ = DownloadStore.instance.store(url, filename: appendix.filename ?? url.lastPathComponent, with: "\(msgId)"); } completionHandler?(); }); resendMessage(content: url, isAttachment: true, encryption: encryption, stanzaId: stanzaId, correctedMessageOriginId: nil); } func resendMessage(content: String, isAttachment: Bool, encryption: ChatEncryption, stanzaId: String, correctedMessageOriginId: String?) { let message = createMessage(text: content, id: stanzaId); if isAttachment { message.oob = content } message.lastMessageCorrectionId = correctedMessageOriginId; if #available(iOS 15.0, *) { let sender = INPerson(personHandle: INPersonHandle(value: account.stringValue, type: .unknown), nameComponents: nil, displayName: AccountManager.getAccount(for: self.account)?.nickname, image: AvatarManager.instance.avatar(for: self.account, on: self.account)?.inImage(), contactIdentifier: nil, customIdentifier: account.stringValue, isMe: true, suggestionType: .instantMessageAddress); let recipient = INPerson(personHandle: INPersonHandle(value: jid.stringValue, type: .unknown), nameComponents: nil, displayName: self.displayName, image: AvatarManager.instance.avatar(for: self.jid, on: self.account)?.inImage(), contactIdentifier: nil, customIdentifier: jid.stringValue, isMe: false, suggestionType: .instantMessageAddress); let intent = INSendMessageIntent(recipients: [recipient], outgoingMessageType: .outgoingMessageText, content: nil, speakableGroupName: nil, conversationIdentifier: "account=\(account.stringValue)|sender=\(jid.stringValue)", serviceName: "Siskin IM", sender: sender, attachments: nil); let interaction = INInteraction(intent: intent, response: nil); interaction.direction = .outgoing; interaction.donate(completion: nil); } send(message: message, encryption: encryption, completionHandler: { result in switch result { case .success(_): DBChatHistoryStore.instance.updateItemState(for: self, stanzaId: correctedMessageOriginId ?? message.id!, from: .outgoing(.unsent), to: .outgoing(.sent), withTimestamp: correctedMessageOriginId != nil ? nil : Date()); case .failure(let error): switch error { case .gone: return; default: break; } DBChatHistoryStore.instance.markOutgoingAsError(for: self, stanzaId: message.id!, errorCondition: .undefined_condition, errorMessage: error.message) } }) } private func send(message: Message, encryption: ChatEncryption, completionHandler: @escaping (Result)->Void) { XmppService.instance.tasksQueue.schedule(for: jid, task: { callback in switch encryption { case .none: super.send(message: message, completionHandler: { result in completionHandler(result); callback(); }); case .omemo: guard let context = self.context as? XMPPClient, context.isConnected else { completionHandler(.failure(.gone(nil))); callback(); return; } message.oob = nil; context.module(.omemo).encode(message: message, completionHandler: { result in switch result { case .successMessage(let encodedMessage, _): guard context.isConnected else { completionHandler(.failure(.gone(nil))) callback(); return; } super.send(message: encodedMessage, completionHandler: { result in completionHandler(result); callback(); }); case .failure(let error): var errorMessage = NSLocalizedString("It was not possible to send encrypted message due to encryption error", comment: "message encryption failure"); switch error { case .noSession: errorMessage = NSLocalizedString("There is no trusted device to send message to", comment: "message encryption failure"); default: break; } completionHandler(.failure(.unexpected_request(errorMessage))); callback(); } }) } }) } public override func isLocal(sender: ConversationEntrySender) -> Bool { switch sender { case .me(_): return true; default: return false; } } } typealias ConversationOptionsProtocol = ChatOptionsProtocol public struct ChatOptions: Codable, ConversationOptionsProtocol, Equatable { var encryption: ChatEncryption?; public var notifications: ConversationNotification = .always; public var confirmMessages: Bool = true; init() {} public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self); if let val = try container.decodeIfPresent(String.self, forKey: .encryption) { encryption = ChatEncryption(rawValue: val); } notifications = ConversationNotification(rawValue: try container.decodeIfPresent(String.self, forKey: .notifications) ?? "") ?? .always; confirmMessages = try container.decodeIfPresent(Bool.self, forKey: .confirmMessages) ?? true; } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self); if encryption != nil { try container.encode(encryption!.rawValue, forKey: .encryption); } if notifications != .always { try container.encode(notifications.rawValue, forKey: .notifications); } try container.encode(confirmMessages, forKey: .confirmMessages); } public func equals(_ options: ChatOptionsProtocol) -> Bool { guard let options = options as? ChatOptions else { return false; } return options == self; } enum CodingKeys: String, CodingKey { case encryption = "encrypt" case notifications = "notifications"; case confirmMessages = "confirmMessages" } } ================================================ FILE: SiskinIM/database/model/conversations/Conversation.swift ================================================ // // Conversation.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import TigaseSQLite3 import Martin import Combine import Shared public enum ConversationFeature { case omemo case httpFileUpload } public protocol Conversation: ConversationProtocol, ConversationKey, DisplayableIdWithKeyProtocol { var status: Presence.Show? { get } var statusPublisher: Published.Publisher { get } var displayName: String { get } var displayNamePublisher: Published.Publisher { get } var id: Int { get } var timestamp: Date { get } var timestampPublisher: AnyPublisher { get } var unread: Int { get } var unreadPublisher: AnyPublisher { get } var lastActivity: LastConversationActivity? { get } var lastActivityPublisher: Published.Publisher { get } var notifications: ConversationNotification { get } var automaticallyFetchPreviews: Bool { get } var markersPublisher: AnyPublisher<[ChatMarker],Never> { get } var features: [ConversationFeature] { get } var featuresPublisher: AnyPublisher<[ConversationFeature],Never> { get } func mark(as markerType: ChatMarker.MarkerType, before: Date, by sender: ConversationEntrySender); func markAsRead(count: Int) -> Bool; func update(lastActivity: LastConversationActivity?, timestamp: Date, isUnread: Bool) -> Bool; func sendMessage(text: String, correctedMessageOriginId: String?); func prepareAttachment(url: URL, completionHandler: @escaping (Result<(URL,Bool,((URL)->URL)?),ShareError>)->Void); func sendAttachment(url: String, appendix: ChatAttachmentAppendix, originalUrl: URL?, completionHandler: (()->Void)?); func canSendChatMarker() -> Bool; func sendChatMarker(_ marker: Message.ChatMarkers, andDeliveryReceipt: Bool); func isLocal(sender: ConversationEntrySender) -> Bool; } import MartinOMEMO extension Conversation { func loadItems(_ type: ConversationLoadType) -> [ConversationEntry] { return DBChatHistoryStore.instance.history(for: self, queryType: type); } func retract(entry: ConversationEntry) { guard context != nil else { return; } DBChatHistoryStore.instance.originId(for: account, with: jid, id: entry.id, completionHandler: { originId in let message = self.createMessageRetraction(forMessageWithId: originId); self.send(message: message, completionHandler: nil); DBChatHistoryStore.instance.retractMessage(for: self, stanzaId: originId, sender: entry.sender, retractionStanzaId: message.id, retractionTimestamp: Date(), serverMsgId: nil, remoteMsgId: nil); }) } } public typealias LastConversationActivity = LastChatActivity public enum LastChatActivity { case message(String, direction: MessageDirection, sender: String?) case attachment(String, direction: MessageDirection, sender: String?) case invitation(String, direction: MessageDirection, sender: String?) case location(String, direction: MessageDirection, sender: String?) static func from(itemType: ItemType?, data: String?, direction: MessageDirection, sender: String?) -> LastChatActivity? { guard itemType != nil else { return nil; } switch itemType! { case .message: return data == nil ? nil : .message(data!, direction: direction, sender: sender); case .location: return data == nil ? nil : .location(data!, direction: direction, sender: sender); case .invitation: return data == nil ? nil : .invitation(data!, direction: direction, sender: sender); case .attachment: return data == nil ? nil : .attachment(data!, direction: direction, sender: sender); case .linkPreview: return nil; case .messageRetracted, .attachmentRetracted: // TODO: Should we notify user that last message was retracted?? return nil; } } } typealias ConversationEncryption = ChatEncryption public enum ChatEncryption: String, Codable, CustomStringConvertible { case none = "none"; case omemo = "omemo"; public var description: String { switch self { case .none: return NSLocalizedString("None", comment: "encyption option"); case .omemo: return NSLocalizedString("OMEMO", comment: "encryption option"); } } } public protocol ChatOptionsProtocol: DatabaseConvertibleStringValue { var notifications: ConversationNotification { get } var confirmMessages: Bool { get } func equals(_ options: ChatOptionsProtocol) -> Bool } public struct ChatMarker: Hashable { let sender: ConversationEntrySender; let timestamp: Date; let type: MarkerType; public enum MarkerType: Int, Comparable, Hashable { case received = 0 case displayed = 1 public var label: String { switch self { case .received: return NSLocalizedString("Received", comment: "label for chat marker") case .displayed: return NSLocalizedString("Displayed", comment: "label for chat marker") } } public static func < (lhs: MarkerType, rhs: MarkerType) -> Bool { return lhs.rawValue < rhs.rawValue; } static func from(chatMarkers: Message.ChatMarkers) -> MarkerType { switch chatMarkers { case .received(_): return .received; case .displayed(_), .acknowledged(_): return .displayed; } } } } extension Conversation { // public func readTillTimestampPublisher(for jid: JID) -> Published.Publisher { // return entry(for: jid).$timestamp; // } // // public func markers(inRange range: ClosedRange) -> [Date] { // return chatMarkers.filter({ (arg0) -> Bool in // let (key, value) = arg0; // return key.account == self.account && key.conversationJID == self.jid && value.timestamp != nil && range.contains(value.timestamp!); // }).map { (arg0) -> Date in // let (_, value) = arg0 // return value.timestamp!; // } // } } ================================================ FILE: SiskinIM/database/model/conversations/ConversationBase.swift ================================================ // // ConversationBase.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import UIKit import Combine import Shared public class ConversationBase: Martin.ConversationBase, Identifiable, Hashable, DisplayableIdWithKeyProtocol { public static func == (lhs: ConversationBase, rhs: ConversationBase) -> Bool { return lhs.id == rhs.id; } public let id: Int; public let dispatcher: QueueDispatcher; private let displayableId: DisplayableIdProtocol; public var displayName: String { return displayableId.displayName; } public var displayNamePublisher: Published.Publisher { return displayableId.displayNamePublisher; } public var status: Presence.Show? { return displayableId.status; } public var statusPublisher: Published.Publisher { return displayableId.statusPublisher; } public var avatarPublisher: AnyPublisher { return displayableId.avatarPublisher; } public var description: String? { return displayableId.description; } public var descriptionPublisher: Published.Publisher { return displayableId.descriptionPublisher; } @Published public private(set) var timestamp: Date; public var timestampPublisher: AnyPublisher { return $timestamp.receive(on: DispatchQueue.main).eraseToAnyPublisher(); } @Published public private(set) var lastActivity: LastChatActivity?; public var lastActivityPublisher: Published.Publisher { return $lastActivity; } @Published public private(set) var unread: Int; public var unreadPublisher: AnyPublisher { return $unread.receive(on: DispatchQueue.main).eraseToAnyPublisher(); } @Published public private(set) var markers: [ConversationEntrySender: ChatMarker] = [:]; public var markersPublisher: AnyPublisher<[ChatMarker],Never> { return $markers.map({ Array($0.values) }).eraseToAnyPublisher(); } @Published public private(set) var features: [ConversationFeature] = []; public var featuresPublisher: AnyPublisher<[ConversationFeature],Never> { return $features.eraseToAnyPublisher(); } public init(dispatcher: QueueDispatcher, context: Context, jid: BareJID, id: Int, timestamp: Date, lastActivity: LastChatActivity?, unread: Int, displayableId: DisplayableIdProtocol) { self.id = id; self.timestamp = timestamp; self.dispatcher = dispatcher; self.lastActivity = lastActivity; self.unread = unread; self.displayableId = displayableId; super.init(context: context, jid: jid); for marker in DBChatMarkersStore.instance.markers(for: (self as! ConversationKey)) { if !self.isLocal(sender: marker.sender) { self.markers[marker.sender] = marker; } } } public func hash(into hasher: inout Hasher) { hasher.combine(id); } public func mark(as markerType: ChatMarker.MarkerType, before: Date, by sender: ConversationEntrySender) { guard !self.isLocal(sender: sender) else { return; } if let marker = markers[sender] { switch markerType { case .received: guard marker.timestamp < before else { return; } case .displayed: guard marker.timestamp <= before else { return; } } } markers[sender] = ChatMarker(sender: sender, timestamp: before, type: markerType); } public func markAsRead(count: Int) -> Bool { return dispatcher.sync(flags: .barrier) { guard unread > 0 else { return false; } unread = max(unread - count, 0); return true } } public func update(lastActivity: LastChatActivity?, timestamp: Date, isUnread: Bool) -> Bool { return dispatcher.sync(flags: .barrier) { if isUnread { unread = unread + 1; } guard self.lastActivity == nil || self.timestamp.compare(timestamp) != .orderedDescending else { return isUnread; } if lastActivity != nil { self.lastActivity = lastActivity; self.timestamp = timestamp; } return true; } } public func refreshMarkers() { let toRemove = self.markers.keys.filter(isLocal(sender:)); for sender in toRemove { self.markers.removeValue(forKey: sender); } } public func isLocal(sender: ConversationEntrySender) -> Bool { return false; } public func update(features: [ConversationFeature]) { self.features = features; } } public class ConversationBaseWithOptions: ConversationBase { @Published private var _options: Options; public var options: Options { return dispatcher.sync { return _options; } } public var optionsPublisher: Published.Publisher { return $_options; } public var notifications: ConversationNotification { return options.notifications; } public init(dispatcher: QueueDispatcher, context: Context, jid: BareJID, id: Int, timestamp: Date, lastActivity: LastChatActivity?, unread: Int, options: Options, displayableId: DisplayableIdProtocol) { self._options = options; super.init(dispatcher: dispatcher, context: context, jid: jid, id: id, timestamp: timestamp, lastActivity: lastActivity, unread: unread, displayableId: displayableId); } public func updateOptions(_ fn: @escaping (inout Options)->Void, completionHandler: (()->Void)? = nil) { dispatcher.async(flags: .barrier) { var options = self._options; fn(&options); if !options.equals(self._options) { DBChatStore.instance.update(options: options, for: self as! Conversation); self._options = options; } completionHandler?(); } } } ================================================ FILE: SiskinIM/database/model/conversations/Room.swift ================================================ // // Room.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import MartinOMEMO import UIKit import Combine import Shared import Intents public class Room: ConversationBaseWithOptions, RoomProtocol, Conversation, RoomWithPushSupportProtocol { open override var defaultMessageType: StanzaType { return .groupchat; } private let occupantsStore = RoomOccupantsStoreBase(); public var occupantsPublisher: AnyPublisher<[MucOccupant], Never> { return occupantsStore.occupantsPublisher; } private let displayable: RoomDisplayableId; @Published public var role: MucRole = .none; @Published public var affiliation: MucAffiliation = .none; @Published public private(set) var state: RoomState = .not_joined() { didSet { switch state { case .joined: DispatchQueue.main.async { self.displayable.status = .online; } case .requested: DispatchQueue.main.async { self.displayable.status = .away; } default: DispatchQueue.main.async { self.displayable.status = nil; } } } } public var statePublisher: AnyPublisher { return $state.eraseToAnyPublisher(); } public var subject: String? { get { return displayable.description; } set { DispatchQueue.main.async { self.displayable.description = newValue; } } } public var name: String? { return options.name; } public var nickname: String { return options.nickname; } public var password: String? { return options.password; } public var automaticallyFetchPreviews: Bool { return true; } public var roomJid: BareJID { return jid; } public var debugDescription: String { return "Room(account: \(account), jid: \(jid))"; } @Published public var roomFeatures: Set = [] { didSet { if self.roomFeatures.contains(.membersOnly) && self.roomFeatures.contains(.nonAnonymous) { if let mucModule = context?.module(.muc) { var members: [JID] = []; let group = DispatchGroup(); for affiliation: MucAffiliation in [.member, .admin, .owner] { group.enter(); mucModule.getRoomAffiliations(from: self, with: affiliation, completionHandler: { result in switch result { case .success(let affs): members.append(contentsOf: affs.map({ $0.jid })); case .failure(_): break; } group.leave(); }); } group.notify(queue: DispatchQueue.global(), execute: { [weak self] in self?.dispatcher.async { self?._members = members; } }) } } } } public enum Feature: String { case membersOnly = "muc_membersonly" case nonAnonymous = "muc_nonanonymous" case messageModeration = "urn:xmpp:message-moderate:0" } private var cancellables: Set = []; init(dispatcher: QueueDispatcher,context: Context, jid: BareJID, id: Int, timestamp: Date, lastActivity: LastChatActivity?, unread: Int, options: RoomOptions) { self.displayable = RoomDisplayableId(displayName: options.name ?? jid.stringValue, status: nil, avatar: AvatarManager.instance.avatarPublisher(for: .init(account: context.userBareJid, jid: jid, mucNickname: nil)), description: nil); super.init(dispatcher: dispatcher, context: context, jid: jid, id: id, timestamp: timestamp, lastActivity: lastActivity, unread: unread, options: options, displayableId: displayable); (context.module(.httpFileUpload) as! HttpFileUploadModule).isAvailablePublisher.combineLatest(self.statePublisher, self.$roomFeatures, { isAvailable, state, roomFeatures -> [ConversationFeature] in var features: [ConversationFeature] = []; if state == .joined { if isAvailable { features.append(.httpFileUpload); } if roomFeatures.contains(.membersOnly) && roomFeatures.contains(.nonAnonymous) { features.append(.omemo); } } return features; }).sink(receiveValue: { [weak self] value in self?.update(features: value); }).store(in: &cancellables); } public override func isLocal(sender: ConversationEntrySender) -> Bool { switch sender { case .occupant(let nickname, let jid): guard let jid = jid else { return nickname == self.nickname; } return jid == account; default: return false; } } private static let nonMembersAffiliations: Set = [.none, .outcast]; private var _members: [JID]?; public var members: [JID]? { return dispatcher.sync { return _members; } } public var occupants: [MucOccupant] { return dispatcher.sync { return self.occupantsStore.occupants; } } public func occupant(nickname: String) -> MucOccupant? { return dispatcher.sync { return occupantsStore.occupant(nickname: nickname); } } public func addOccupant(nickname: String, presence: Presence) -> MucOccupant { let occupant = MucOccupant(nickname: nickname, presence: presence, for: self); dispatcher.async(flags: .barrier) { self.occupantsStore.add(occupant: occupant); if let jid = occupant.jid { if !Room.nonMembersAffiliations.contains(occupant.affiliation) { if !(self._members?.contains(jid) ?? false) { self._members?.append(jid); } } else { self._members = self._members?.filter({ $0 != jid }); } } } return occupant; } public func remove(occupant: MucOccupant) { dispatcher.async(flags: .barrier) { self.occupantsStore.remove(occupant: occupant); if let jid = occupant.jid { self._members = self._members?.filter({ $0 != jid }); } } } public func addTemp(nickname: String, occupant: MucOccupant) { dispatcher.async(flags: .barrier) { self.occupantsStore.addTemp(nickname: nickname, occupant: occupant); } } public func removeTemp(nickname: String) -> MucOccupant? { return dispatcher.sync(flags: .barrier) { return occupantsStore.removeTemp(nickname: nickname); } } public func updateRoom(name: String?) { updateOptions({ options in options.name = name; }); } public override func updateOptions(_ fn: @escaping (inout RoomOptions) -> Void, completionHandler: (()->Void)? = nil) { super.updateOptions(fn, completionHandler: completionHandler); DispatchQueue.main.async { self.displayable.displayName = self.options.name ?? self.jid.stringValue; } } public func update(state: RoomState) { dispatcher.async(flags: .barrier) { self.state = state; if state != .joined && state != .requested { self.occupantsStore.removeAll(); self._members = nil; } } } public override func createMessage(text: String, id: String, type: StanzaType) -> Message { let msg = super.createMessage(text: text, id: id, type: type); msg.isMarkable = true; return msg; } public func sendMessage(text: String, correctedMessageOriginId: String?) { let encryption = self.features.contains(.omemo) ? self.options.encryption ?? Settings.messageEncryption : .none; let message = self.createMessage(text: text); message.lastMessageCorrectionId = correctedMessageOriginId; if encryption == .omemo, let omemoModule = context?.modulesManager.module(.omemo) { guard let members = self.members else { return; } omemoModule.encode(message: message, for: members.map({ $0.bareJid }), completionHandler: { result in switch result { case .failure(let error): break; case .successMessage(let message, let fingerprint): super.send(message: message, completionHandler: nil); if #available(iOS 15.0, *) { let sender = INPerson(personHandle: INPersonHandle(value: self.account.stringValue, type: .unknown), nameComponents: nil, displayName: self.nickname, image: AvatarManager.instance.avatar(for: self.account, on: self.account)?.inImage(), contactIdentifier: nil, customIdentifier: self.account.stringValue, isMe: true, suggestionType: .instantMessageAddress); let recipient = INPerson(personHandle: INPersonHandle(value: self.jid.stringValue, type: .unknown), nameComponents: nil, displayName: self.displayName, image: AvatarManager.instance.avatar(for: self.jid, on: self.account)?.inImage(), contactIdentifier: nil, customIdentifier: self.jid.stringValue, isMe: false, suggestionType: .instantMessageAddress); let intent = INSendMessageIntent(recipients: [recipient], outgoingMessageType: .outgoingMessageText, content: nil, speakableGroupName: INSpeakableString(spokenPhrase: self.displayName), conversationIdentifier: "account=\(self.account.stringValue)|sender=\(self.jid.stringValue)", serviceName: "Siskin IM", sender: sender, attachments: nil); let interaction = INInteraction(intent: intent, response: nil); interaction.direction = .outgoing; interaction.donate(completion: nil); } if correctedMessageOriginId == nil { DBChatHistoryStore.instance.appendItem(for: self, state: .outgoing(.sent), sender: .occupant(nickname: self.nickname, jid: nil), type: .message, timestamp: Date(), stanzaId: message.id, serverMsgId: nil, remoteMsgId: nil, data: text, options: .init(recipient: .none, encryption: .decrypted(fingerprint: fingerprint), isMarkable: true), linkPreviewAction: .auto, completionHandler: nil); } } }); } else { super.send(message: message, completionHandler: nil); if #available(iOS 15.0, *) { let sender = INPerson(personHandle: INPersonHandle(value: self.account.stringValue, type: .unknown), nameComponents: nil, displayName: self.nickname, image: AvatarManager.instance.avatar(for: self.account, on: self.account)?.inImage(), contactIdentifier: nil, customIdentifier: self.account.stringValue, isMe: true, suggestionType: .instantMessageAddress); let recipient = INPerson(personHandle: INPersonHandle(value: self.jid.stringValue, type: .unknown), nameComponents: nil, displayName: self.displayName, image: AvatarManager.instance.avatar(for: self.jid, on: self.account)?.inImage(), contactIdentifier: nil, customIdentifier: self.jid.stringValue, isMe: false, suggestionType: .instantMessageAddress); let intent = INSendMessageIntent(recipients: [recipient], outgoingMessageType: .outgoingMessageText, content: nil, speakableGroupName: INSpeakableString(spokenPhrase: self.displayName), conversationIdentifier: "account=\(self.account.stringValue)|sender=\(self.jid.stringValue)", serviceName: "Siskin IM", sender: sender, attachments: nil); let interaction = INInteraction(intent: intent, response: nil); interaction.direction = .outgoing; interaction.donate(completion: nil); } if correctedMessageOriginId == nil { DBChatHistoryStore.instance.appendItem(for: self, state: .outgoing(.sent), sender: .occupant(nickname: self.nickname, jid: nil), type: .message, timestamp: Date(), stanzaId: message.id, serverMsgId: nil, remoteMsgId: nil, data: text, options: .init(recipient: .none, encryption: .none, isMarkable: true), linkPreviewAction: .auto, completionHandler: nil); } } } public func prepareAttachment(url originalURL: URL, completionHandler: @escaping (Result<(URL, Bool, ((URL) -> URL)?), ShareError>) -> Void) { let encryption = self.features.contains(.omemo) ? self.options.encryption ?? Settings.messageEncryption : .none; switch encryption { case .none: completionHandler(.success((originalURL, false, nil))); case .omemo: guard let omemoModule: OMEMOModule = self.context?.module(.omemo), let data = try? Data(contentsOf: originalURL) else { completionHandler(.failure(.unknownError)); return; } let result = omemoModule.encryptFile(data: data); switch result { case .success(let (encryptedData, hash)): let tmpFile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString); do { try encryptedData.write(to: tmpFile); completionHandler(.success((tmpFile, true, { url in var parts = URLComponents(url: url, resolvingAgainstBaseURL: true)!; parts.scheme = "aesgcm"; parts.fragment = hash; let shareUrl = parts.url!; return shareUrl; }))); } catch { completionHandler(.failure(.noAccessError)); } case .failure(_): completionHandler(.failure(.unknownError)); } } } public func sendAttachment(url uploadedUrl: String, appendix: ChatAttachmentAppendix, originalUrl: URL?, completionHandler: (() -> Void)?) { guard ((self.context as? XMPPClient)?.state ?? .disconnected()) == .connected(), self.state == .joined else { completionHandler?(); return; } let encryption = self.features.contains(.omemo) ? self.options.encryption ?? Settings.messageEncryption : .none; let message = self.createMessage(text: uploadedUrl); if encryption == .omemo, let omemoModule = context?.modulesManager.module(.omemo) { guard let members = self.members else { completionHandler?(); return; } omemoModule.encode(message: message, for: members.map({ $0.bareJid }), completionHandler: { result in switch result { case .failure(let error): break; case .successMessage(let message, let fingerprint): super.send(message: message, completionHandler: nil); if #available(iOS 15.0, *) { let sender = INPerson(personHandle: INPersonHandle(value: self.account.stringValue, type: .unknown), nameComponents: nil, displayName: self.nickname, image: AvatarManager.instance.avatar(for: self.account, on: self.account)?.inImage(), contactIdentifier: nil, customIdentifier: self.account.stringValue, isMe: true, suggestionType: .instantMessageAddress); let recipient = INPerson(personHandle: INPersonHandle(value: self.jid.stringValue, type: .unknown), nameComponents: nil, displayName: self.displayName, image: AvatarManager.instance.avatar(for: self.jid, on: self.account)?.inImage(), contactIdentifier: nil, customIdentifier: self.jid.stringValue, isMe: false, suggestionType: .instantMessageAddress); let intent = INSendMessageIntent(recipients: [recipient], outgoingMessageType: .outgoingMessageText, content: nil, speakableGroupName: INSpeakableString(spokenPhrase: self.displayName), conversationIdentifier: "account=\(self.account.stringValue)|sender=\(self.jid.stringValue)", serviceName: "Siskin IM", sender: sender, attachments: nil); let interaction = INInteraction(intent: intent, response: nil); interaction.direction = .outgoing; interaction.donate(completion: nil); } DBChatHistoryStore.instance.appendItem(for: self, state: .outgoing(.sent), sender: .occupant(nickname: self.nickname, jid: nil), type: .attachment, timestamp: Date(), stanzaId: message.id, serverMsgId: nil, remoteMsgId: nil, data: uploadedUrl, appendix: appendix, options: .init(recipient: .none, encryption: .decrypted(fingerprint: fingerprint), isMarkable: true), linkPreviewAction: .auto, completionHandler: { msgId in if let url = originalUrl { _ = DownloadStore.instance.store(url, filename: appendix.filename ?? url.lastPathComponent, with: "\(msgId)"); } }); } completionHandler?(); }); } else { message.oob = uploadedUrl; super.send(message: message, completionHandler: nil); if #available(iOS 15.0, *) { let sender = INPerson(personHandle: INPersonHandle(value: self.account.stringValue, type: .unknown), nameComponents: nil, displayName: self.nickname, image: AvatarManager.instance.avatar(for: self.account, on: self.account)?.inImage(), contactIdentifier: nil, customIdentifier: self.account.stringValue, isMe: true, suggestionType: .instantMessageAddress); let recipient = INPerson(personHandle: INPersonHandle(value: self.jid.stringValue, type: .unknown), nameComponents: nil, displayName: self.displayName, image: AvatarManager.instance.avatar(for: self.jid, on: self.account)?.inImage(), contactIdentifier: nil, customIdentifier: self.jid.stringValue, isMe: false, suggestionType: .instantMessageAddress); let intent = INSendMessageIntent(recipients: [recipient], outgoingMessageType: .outgoingMessageText, content: nil, speakableGroupName: INSpeakableString(spokenPhrase: self.displayName), conversationIdentifier: "account=\(self.account.stringValue)|sender=\(self.jid.stringValue)", serviceName: "Siskin IM", sender: sender, attachments: nil); let interaction = INInteraction(intent: intent, response: nil); interaction.direction = .outgoing; interaction.donate(completion: nil); } DBChatHistoryStore.instance.appendItem(for: self, state: .outgoing(.sent), sender: .occupant(nickname: self.nickname, jid: nil), type: .attachment, timestamp: Date(), stanzaId: message.id, serverMsgId: nil, remoteMsgId: nil, data: uploadedUrl, appendix: appendix, options: .init(recipient: .none, encryption: .none, isMarkable: true), linkPreviewAction: .auto, completionHandler: { msgId in if let url = originalUrl { _ = DownloadStore.instance.store(url, filename: appendix.filename ?? url.lastPathComponent, with: "\(msgId)"); } }); } } public func sendPrivateMessage(to occupant: MucOccupant, text: String) { let message = self.createPrivateMessage(text, recipientNickname: occupant.nickname); let options = ConversationEntry.Options(recipient: .occupant(nickname: occupant.nickname), encryption: .none, isMarkable: false) DBChatHistoryStore.instance.appendItem(for: self, state: .outgoing(.sent), sender: .occupant(nickname: self.options.nickname, jid: nil), type: .message, timestamp: Date(), stanzaId: message.id, serverMsgId: nil, remoteMsgId: nil, data: text, appendix: nil, options: options, linkPreviewAction: .auto, completionHandler: nil); self.send(message: message, completionHandler: nil); } public func moderate(entry: ConversationEntry, completionHandler: @escaping (Result)->Void) { guard roomFeatures.contains(.messageModeration), let stableIds = DBChatHistoryStore.instance.stableIds(forId: entry.id), let remoteId = stableIds.remote else { completionHandler(.failure(.feature_not_implemented)); return; } moderateMessage(id: remoteId, completionHandler: completionHandler); } public func canSendChatMarker() -> Bool { return self.roomFeatures.contains(.membersOnly) && self.roomFeatures.contains(.nonAnonymous); } public func sendChatMarker(_ marker: Message.ChatMarkers, andDeliveryReceipt receipt: Bool) { guard Settings.confirmMessages else { return; } guard ((self.context as? XMPPClient)?.state ?? .disconnected()) == .connected(), self.state == .joined else { return; } if self.options.confirmMessages && canSendChatMarker() { let message = self.createMessage(); message.chatMarkers = marker; message.hints = [.store] if receipt { message.messageDelivery = .received(id: marker.id) } self.send(message: message, completionHandler: nil); } else if case .displayed(_) = marker { let message = self.createPrivateMessage(recipientNickname: self.nickname); message.chatMarkers = marker; message.hints = [.store] self.send(message: message, completionHandler: nil); } } private class RoomDisplayableId: DisplayableIdProtocol { @Published var displayName: String var displayNamePublisher: Published.Publisher { return $displayName; } @Published var status: Presence.Show? var statusPublisher: Published.Publisher { return $status; } @Published var description: String?; var descriptionPublisher: Published.Publisher { return $description; } let avatar: Avatar; var avatarPublisher: AnyPublisher { return avatar.avatarPublisher.replaceNil(with: AvatarManager.instance.defaultGroupchatAvatar).eraseToAnyPublisher(); } init(displayName: String, status: Presence.Show?, avatar: Avatar, description: String?) { self.displayName = displayName; self.description = description; self.status = status; self.avatar = avatar; } } } public struct RoomOptions: Codable, ChatOptionsProtocol, Equatable { public var name: String?; public let nickname: String; public var password: String?; var encryption: ChatEncryption?; public var notifications: ConversationNotification = .mention; public var confirmMessages: Bool = true; init(nickname: String, password: String?) { self.nickname = nickname; self.password = password; } init() { nickname = ""; } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self); encryption = try container.decodeIfPresent(String.self, forKey: .encryption).flatMap(ChatEncryption.init(rawValue: )); name = try container.decodeIfPresent(String.self, forKey: .name) nickname = try container.decodeIfPresent(String.self, forKey: .nick) ?? ""; password = try container.decodeIfPresent(String.self, forKey: .password) notifications = ConversationNotification(rawValue: try container.decodeIfPresent(String.self, forKey: .notifications) ?? "") ?? .mention; confirmMessages = try container.decodeIfPresent(Bool.self, forKey: .confirmMessages) ?? true; } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self); try container.encodeIfPresent(encryption?.rawValue, forKey: .encryption); try container.encodeIfPresent(name, forKey: .name); try container.encodeIfPresent(nickname, forKey: .nick); try container.encodeIfPresent(password, forKey: .password); if notifications != .mention { try container.encode(notifications.rawValue, forKey: .notifications); } try container.encode(confirmMessages, forKey: .confirmMessages) } public func equals(_ options: ChatOptionsProtocol) -> Bool { guard let options = options as? RoomOptions else { return false; } return options == self; } enum CodingKeys: String, CodingKey { case encryption = "encrypt" case name = "name"; case nick = "nick"; case password = "password"; case notifications = "notifications"; case confirmMessages = "confirmMessages" } } ================================================ FILE: SiskinIM/database/model/history/AppendixProtocol.swift ================================================ // // AppendixProtocol.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // // import Foundation import TigaseSQLite3 public protocol AppendixProtocol: Codable, DatabaseConvertibleStringValue { } ================================================ FILE: SiskinIM/database/model/history/ConversationAttachment.swift ================================================ // // ConversationAttachment.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin public struct ChatAttachmentAppendix: AppendixProtocol, Hashable { var state: State = .new; var filesize: Int? = nil; var mimetype: String? = nil; var filename: String? = nil; init() {} public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self); state = State(rawValue: try container.decode(Int.self, forKey: .state))!; filesize = try container.decodeIfPresent(Int.self, forKey: .filesize); mimetype = try container.decodeIfPresent(String.self, forKey: .mimetype); filename = try container.decodeIfPresent(String.self, forKey: .filename); } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self); try container.encode(state.rawValue, forKey: .state); if let filesize = self.filesize { try container.encode(filesize, forKey: .filesize); } if let mimetype = self.mimetype { try container.encode(mimetype, forKey: .mimetype); } if let filename = self.filename { try container.encode(filename, forKey: .filename); } } enum CodingKeys: String, CodingKey { case state = "state" case filesize = "size" case mimetype = "mimetype" case filename = "name" } enum State: Int { case new case downloaded case removed case tooBig case error case gone } } ================================================ FILE: SiskinIM/database/model/history/ConversationEntry.swift ================================================ // // ConversationEntry.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import CoreLocation public enum ConversationEntryPayload: Hashable { case message(message: String, correctionTimestamp: Date?) case attachment(url: String, appendix: ChatAttachmentAppendix) case linkPreview(url: String) case messageRetracted case invitation(message: String?, appendix: ChatInvitationAppendix) case deleted case unreadMessages case marker(type: ChatMarker.MarkerType, senders: [ConversationEntrySender]) case location(location: CLLocationCoordinate2D) // var itemType: ItemType { // switch self { // case .message(_, _): // return .message; // case .attachment(_, _): // return .attachment; // case .linkPreview(_): // return .linkPreview; // case .messageRetracted: // return .messageRetracted; // case .invitation(_, _): // return .invitation; // case .deleted: // assertionFailure("Unsupported converstion from ConversationEntryPayload.deleted to ItemType"); // return .message; // case .unreadMessages: // assertionFailure("Unsupported converstion from ConversationEntryPayload.unreadMessages to ItemType"); // return .message; // case .marker(_, _): // assertionFailure("Unsupported converstion from ConversationEntryPayload.marker to ItemType"); // return .message; // case .location(_): // return .location; // } // } } public final class ConversationEntry: Hashable { public static func == (lhs: ConversationEntry, rhs: ConversationEntry) -> Bool { lhs.id == rhs.id && lhs.timestamp == rhs.timestamp && lhs.payload == rhs.payload && lhs.sender == rhs.sender && lhs.state == rhs.state && lhs.options == rhs.options; } let id: Int; let conversation: ConversationKey; let timestamp: Date; let state: ConversationEntryState; let sender: ConversationEntrySender; let payload: ConversationEntryPayload let options: ConversationEntry.Options; init(id: Int, conversation: ConversationKey, timestamp: Date, state: ConversationEntryState, sender: ConversationEntrySender, payload: ConversationEntryPayload, options: ConversationEntry.Options) { self.id = id; self.conversation = conversation; self.timestamp = timestamp; self.state = state; self.sender = sender; self.payload = payload; self.options = options; } func isMergeable() -> Bool { switch payload { case .message(let message, _): return !message.starts(with: "/me "); default: return false; } } func isMergeable(with item: ConversationEntry) -> Bool { // check if entries can be mergable (some are not mergable) guard isMergeable() && item.isMergeable() else { return false; } guard sender == item.sender else { return false; } guard self.options.encryption == item.options.encryption else { return false; } // check encryption state and sender and direction as well.. // maybe we should use state (direction) as nil if not set?? // we could move 'encryption' to 'ChatMessageAppendix' in .message() return abs(timestamp.timeIntervalSince(item.timestamp)) < allowedTimeDiff(); } public func hash(into hasher: inout Hasher) { hasher.combine(id); hasher.combine(timestamp); hasher.combine(sender); hasher.combine(state); hasher.combine(payload); hasher.combine(options); } func allowedTimeDiff() -> TimeInterval { // FIXME: add this setting // switch settings.messageGrouping { // case .none: // return -1.0; // case .always: // return 60.0 * 60.0 * 24.0; // case .smart: return 30.0; // } } } extension ConversationEntry { struct Options: Hashable { let recipient: ConversationEntryRecipient; let encryption: ConversationEntryEncryption; let isMarkable: Bool; static let none = Options(recipient: .none, encryption: .none, isMarkable: false); } } public protocol ConversationEntryRelated { var order: ConversationEntry.Order { get } } extension ConversationEntry { public enum Order { case first case last } } extension ConversationEntry: Comparable { public static func < (it1: ConversationEntry, it2: ConversationEntry) -> Bool { let unsent1 = it1.state.isUnsent; let unsent2 = it2.state.isUnsent; if unsent1 == unsent2 { let result = it1.timestamp.compare(it2.timestamp); guard result == .orderedSame else { return result == .orderedAscending ? false : true; } if it1.id == it2.id || (it1.id == -1 || it2.id == -1) { if let i1 = it1 as? ConversationEntryRelated { switch i1.order { case .first: return false; case .last: return true; } } if let i2 = it2 as? ConversationEntryRelated { switch i2.order { case .first: return true; case .last: return false; } } } // this does not work well if id is -1.. return it1.id < it2.id ? false : true; } else { if unsent1 { return false; } return true; } } } ================================================ FILE: SiskinIM/database/model/history/ConversationEntryEncryption.swift ================================================ // // ConversationEntryEncryption.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation public enum ConversationEntryEncryption: Hashable { case none case decrypted(fingerprint: String?) case decryptionFailed(errorCode: Int) case notForThisDevice func message() -> String? { switch self { case .none, .decrypted(_): return nil; case .decryptionFailed(let errorCode): return String.localizedStringWithFormat(NSLocalizedString("Message decryption failed! Error code: %d", comment: "message encryption failure"), errorCode); case .notForThisDevice: return NSLocalizedString("Message was not encrypted for this device", comment: "message encryption failure"); } } var fingerprint: String? { switch self { case .decrypted(let fingerprint): return fingerprint; default: return nil; } } var errorCode: Int? { switch self { case .decryptionFailed(let errorCode): return errorCode; default: return nil; } } var value: MessageEncryption { switch self { case .none: return .none; case .decrypted(_): return .decrypted; case .decryptionFailed: return .decryptionFailed; case .notForThisDevice: return .notForThisDevice; } } public static func == (lhs: ConversationEntryEncryption, rhs: ConversationEntryEncryption) -> Bool { return lhs.value == rhs.value; } } ================================================ FILE: SiskinIM/database/model/history/ConversationEntryRecipient.swift ================================================ // // ConversationEntryRecipient.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation enum ConversationEntryRecipient: Hashable { case none case occupant(nickname: String) var nickname: String? { switch self { case .none: return nil; case .occupant(let nickname): return nickname; } } } ================================================ FILE: SiskinIM/database/model/history/ConversationEntrySender.swift ================================================ // // ConversationEntrySender.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin public enum ConversationEntrySender: Hashable { case none case me(nickname: String) case buddy(nickname: String) case occupant(nickname: String, jid: BareJID?) case participant(id: String, nickname: String, jid: BareJID?) case channel var nickname: String? { switch self { case .me(let nickname): return nickname; case .buddy(let nickname), .occupant(let nickname, _), .participant(_, let nickname, _): return nickname; case .none, .channel: return nil; } } func avatar(for key: ConversationKey) -> Avatar? { switch self { case .me: return AvatarManager.instance.avatarPublisher(for: .init(account: key.account, jid: key.account, mucNickname: nil)); case .buddy(_): return AvatarManager.instance.avatarPublisher(for: .init(account: key.account, jid: key.jid, mucNickname: nil)); case .occupant(let nickname, let jid): if let jid = jid { return AvatarManager.instance.avatarPublisher(for: .init(account: key.account, jid: jid, mucNickname: nil)); } else { return AvatarManager.instance.avatarPublisher(for: .init(account: key.account, jid: key.jid, mucNickname: nickname)); } case .participant(let participantId, _, let jid): if let jid = jid { return AvatarManager.instance.avatarPublisher(for: .init(account: key.account, jid: jid, mucNickname: nil)); } else { return AvatarManager.instance.avatarPublisher(for: .init(account: key.account, jid: BareJID(localPart: "\(participantId)#\(key.jid.localPart ?? "")", domain: key.jid.domain), mucNickname: nil)); } case .none, .channel: return nil; } } var isGroupchat: Bool { switch self { case .none, .buddy(_), .me(_): return false; default: return true; } } static func me(conversation: ConversationKey) -> ConversationEntrySender { return .me(nickname: AccountManager.getAccount(for: conversation.account)?.nickname ?? conversation.account.stringValue); } static func buddy(conversation: ConversationKey) -> ConversationEntrySender { if let conv = conversation as? Conversation { return .buddy(nickname: conv.displayName); } else { return .buddy(nickname: DBRosterStore.instance.item(for: conversation.account, jid: JID(conversation.jid))?.name ?? conversation.jid.stringValue); } } } ================================================ FILE: SiskinIM/database/model/history/ConversationEntryState.swift ================================================ // // ConversationEntryState.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation public enum ConversationEntryIncomingState: Hashable { case received case displayed var isUnread: Bool { return self == .received; } } public enum ConversationEntryOutogingState: Hashable { case unsent case sent case delivered case displayed } public enum ConversationEntryState: Hashable { case none case incoming(ConversationEntryIncomingState) case outgoing(ConversationEntryOutogingState) case incoming_error(ConversationEntryIncomingState, errorMessage: String? = nil) case outgoing_error(ConversationEntryIncomingState, errorMessage: String? = nil) public static func ==(lhs: ConversationEntryState, rhs: ConversationEntryState) -> Bool { return lhs.code == rhs.code; } static func from(code: Int, errorMessage: String?) -> ConversationEntryState { switch code { case 0: return .incoming(.displayed); case 1: return .outgoing(.sent); case 2: return .incoming(.received); case 3: return .outgoing(.unsent) case 4: return .incoming_error(.displayed, errorMessage: errorMessage); case 5: return .outgoing_error(.displayed, errorMessage: errorMessage); case 6: return .incoming_error(.received, errorMessage: errorMessage); case 7: return .outgoing_error(.received, errorMessage: errorMessage); case 9: return .outgoing(.delivered); case 11: return .outgoing(.displayed); default: assert(false, "Invalid conversation entry state code") return .outgoing(.sent) } } // x % 2 == 0 - incoming // x % 2 == 1 - outgoing var code: Int { switch self { case .incoming(let state): switch state { case .received: return 2; case .displayed: return 0; } case .outgoing(let state): switch state { case .unsent: return 3; case .sent: return 1; case .delivered: return 9; case .displayed: return 11; } case .incoming_error(let state, _): switch state { case .received: return 6; case .displayed: return 4; } case .outgoing_error(let state, _): switch state { case .received: return 7; case .displayed: return 5; } case .none: //assert(false, "Invalid conversation entry state code") return -1; } } var rawValue: Int { return code; } var direction: MessageDirection { switch self { case .incoming(_), .incoming_error(_, _): return .incoming; case .outgoing(_), .outgoing_error(_, _): return .outgoing; case .none: return .incoming; } } var isError: Bool { switch self { case .incoming_error(_, _), .outgoing_error(_, _): return true; default: return false; } } var isUnread: Bool { switch self { case .incoming(let state): return state.isUnread; default: return false; } } var isUnsent: Bool { switch self { case .outgoing(let state): return state == .unsent; default: return false; } } var errorMessage: String? { switch self { case .incoming_error(_, let msg), .outgoing_error(_, let msg): return msg; default: return nil; } } } ================================================ FILE: SiskinIM/database/model/history/ConversationInvitation.swift ================================================ // // ConversationInvitation.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin public struct ChatInvitationAppendix: AppendixProtocol, Hashable { let type: InvitationType; let inviter: BareJID; let invitee: BareJID; let channel: BareJID; let token: String?; public init(mixInvitation: MixInvitation) { type = .mix; inviter = mixInvitation.inviter; invitee = mixInvitation.invitee; channel = mixInvitation.channel; token = mixInvitation.token; } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self); type = InvitationType(rawValue: try container.decode(Int.self, forKey: .type))!; inviter = try container.decode(BareJID.self, forKey: .inveter); invitee = try container.decode(BareJID.self, forKey: .invetee); channel = try container.decode(BareJID.self, forKey: .channel); token = try container.decodeIfPresent(String.self, forKey: .token); } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self); try container.encode(type.rawValue, forKey: .type); try container.encode(inviter, forKey: .inveter); try container.encode(invitee, forKey: .invetee); try container.encode(channel, forKey: .channel); try container.encodeIfPresent(token, forKey: .token); } public func mixInvitation() -> MixInvitation? { guard type == .mix else { return nil; } return MixInvitation(inviter: inviter, invitee: invitee, channel: channel, token: token); } enum CodingKeys: String, CodingKey { case type = "type" case invetee = "invitee"; case inveter = "inviter"; case channel = "channel"; case token = "token"; } enum InvitationType: Int { case mix = 1 } } ================================================ FILE: SiskinIM/database/model/history/ConversationKey.swift ================================================ // // ConversationKey.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin public protocol ConversationKey: CustomDebugStringConvertible { var account: BareJID { get } var jid: BareJID { get } } public class ConversationKeyItem: ConversationKey { public let account: BareJID; public let jid: BareJID; public var debugDescription: String { return "ConversationKeyItem(account: \(account), jid: \(jid))"; } init(account: BareJID, jid: BareJID) { self.account = account; self.jid = jid; } } ================================================ FILE: SiskinIM/db-schema-1.sql ================================================ CREATE TABLE IF NOT EXISTS chats ( id INTEGER PRIMARY KEY AUTOINCREMENT, account TEXT NOT NULL, jid TEXT NOT NULL, type INTEGER NOT NULL, timestamp INTEGER, thread_id TEXT, resource TEXT, nickname TEXT, password TEXT, room_state INTEGER ); CREATE TABLE IF NOT EXISTS chat_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, account TEXT NOT NULL, jid TEXT NOT NULL, author_jid TEXT, author_nickname TEXT, timestamp INTEGER, item_type INTEGER, data TEXT, stanza_id TEXT, state INTEGER, preview TEXT ); CREATE INDEX IF NOT EXISTS chat_history_jid_idx on chats ( jid, account ); CREATE TABLE IF NOT EXISTS roster_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, account TEXT NOT NULL, jid TEXT NOT NULL, name TEXT, subscription TEXT, timestamp INTEGER, ask INTEGER ); CREATE TABLE IF NOT EXISTS roster_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT ); CREATE TABLE IF NOT EXISTS roster_items_groups ( item_id INTEGER NOT NULL, group_id INTEGER NOT NULL, FOREIGN KEY(item_id) REFERENCES roster_items(id), FOREIGN KEY(group_id) REFERENCES roster_groups(id) ); CREATE TABLE IF NOT EXISTS vcards_cache ( id INTEGER PRIMARY KEY AUTOINCREMENT, jid TEXT NOT NULL, data TEXT, timestamp INTEGER ); CREATE INDEX IF NOT EXISTS vcards_cache_jid_idx on vcards_cache ( jid ); CREATE TABLE IF NOT EXISTS caps_features ( node TEXT NOT NULL, feature TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS caps_features_node_idx on caps_features ( node ); CREATE INDEX IF NOT EXISTS caps_features_feature_idx on caps_features ( feature ); CREATE TABLE IF NOT EXISTS caps_identities ( node TEXT NOT NULL, name TEXT, type TEXT, category TEXT ); CREATE INDEX IF NOT EXISTS caps_indentities_node_idx on caps_identities ( node ); CREATE TABLE IF NOT EXISTS avatars_cache ( id INTEGER PRIMARY KEY AUTOINCREMENT, jid TEXT NOT NULL, account TEXT NOT NULL, hash TEXT NOT NULL, type TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS avatars_cache_jid_idx on avatars_cache ( jid ); ================================================ FILE: SiskinIM/db-schema-10.sql ================================================ ALTER TABLE chat_history ADD COLUMN recipient_nickname TEXT; ================================================ FILE: SiskinIM/db-schema-11.sql ================================================ ALTER TABLE roster_items ADD COLUMN annotations TEXT; ALTER TABLE chat_history ADD COLUMN server_msg_id TEXT; ALTER TABLE chat_history ADD COLUMN remote_msg_id TEXT; ALTER TABLE chat_history ADD COLUMN participant_id TEXT; CREATE INDEX IF NOT EXISTS chat_history_account_jid_server_msg_id_idx on chat_history ( account, server_msg_id ); ================================================ FILE: SiskinIM/db-schema-12.sql ================================================ ALTER TABLE chat_history ADD COLUMN master_id INT; ALTER TABLE chat_history ADD COLUMN correction_stanza_id TEXT; ALTER TABLE chat_history ADD COLUMN correction_timestamp INTEGER; ================================================ FILE: SiskinIM/db-schema-13.sql ================================================ CREATE TABLE IF NOT EXISTS chat_history_sync ( id TEXT NOT NULL COLLATE NOCASE, account TEXT NOT NULL COLLATE NOCASE, component TEXT COLLATE NOCASE, from_timestamp INTEGER NOT NULL, from_id TEXT, to_timestamp INTEGER NOT NULL ); ================================================ FILE: SiskinIM/db-schema-14.sql ================================================ CREATE TABLE IF NOT EXISTS chat_markers ( account TEXT NOT NULL COLLATE NOCASE, jid TEXT NOT NULL COLLATE NOCASE, sender_nick TEXT NOT NULL, sender_id TEXT NOT NULL, sender_jid TEXT NOT NULL, timestamp INTEGER NOT NULL, type INTEGER NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS chat_markers_key on chat_markers ( account, jid, sender_nick, sender_id, sender_jid ); ================================================ FILE: SiskinIM/db-schema-2.sql ================================================ ALTER TABLE chats RENAME TO chats_old; CREATE TABLE IF NOT EXISTS chats ( id INTEGER PRIMARY KEY AUTOINCREMENT, account TEXT NOT NULL COLLATE NOCASE, jid TEXT NOT NULL COLLATE NOCASE, type INTEGER NOT NULL, timestamp INTEGER, thread_id TEXT, resource TEXT, nickname TEXT, password TEXT, room_state INTEGER ); INSERT INTO chats ( account, jid, type, timestamp, thread_id, resource, nickname, password, room_state ) SELECT account, jid, type, timestamp, thread_id, resource, nickname, password, room_state FROM chats_old; DROP TABLE chats_old; CREATE INDEX IF NOT EXISTS chat_jid_idx on chats ( jid, account ); ALTER TABLE chat_history RENAME TO chat_history_old; CREATE TABLE IF NOT EXISTS chat_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, account TEXT NOT NULL COLLATE NOCASE, jid TEXT NOT NULL COLLATE NOCASE, author_jid TEXT COLLATE NOCASE, author_nickname TEXT, timestamp INTEGER, item_type INTEGER, data TEXT, stanza_id TEXT, state INTEGER, preview TEXT, error TEXT ); INSERT INTO chat_history ( account, jid, author_jid, author_nickname, timestamp, item_type, data, stanza_id, state, preview ) SELECT account, jid, author_jid, author_nickname, timestamp, item_type, data, stanza_id, state, preview FROM chat_history_old; DROP TABLE chat_history_old; CREATE INDEX IF NOT EXISTS chat_history_account_jid_timestamp_idx on chat_history ( account, jid, timestamp ); CREATE INDEX IF NOT EXISTS chat_history_account_jid_state_idx on chat_history ( account, jid, state ); ALTER TABLE roster_items RENAME TO roster_items_old; ALTER TABLE roster_items_groups RENAME TO roster_items_groups_old; CREATE TABLE IF NOT EXISTS roster_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, account TEXT NOT NULL COLLATE NOCASE, jid TEXT NOT NULL COLLATE NOCASE, name TEXT, subscription TEXT, timestamp INTEGER, ask INTEGER ); INSERT INTO roster_items ( account, jid, name, subscription, timestamp, ask ) SELECT account, jid, name, subscription, timestamp, ask FROM roster_items_old; CREATE TABLE IF NOT EXISTS roster_items_groups ( item_id INTEGER NOT NULL, group_id INTEGER NOT NULL, FOREIGN KEY(item_id) REFERENCES roster_items(id), FOREIGN KEY(group_id) REFERENCES roster_groups(id) ); INSERT INTO roster_items_groups ( item_id, group_id ) SELECT i.id, go.group_id FROM roster_items_groups_old go INNER JOIN roster_items_old io on io.id = go.item_id INNER JOIN roster_items i on i.jid = io.jid; DROP TABLE roster_items_groups_old; DROP TABLE roster_items_old; CREATE INDEX IF NOT EXISTS roster_item_jid_idx on roster_items ( jid, account ); CREATE INDEX IF NOT EXISTS roster_item_groups_item_id_idx ON roster_items_groups (item_id); CREATE INDEX IF NOT EXISTS roster_item_groups_group_id_idx ON roster_items_groups (group_id); ALTER TABLE vcards_cache RENAME TO vcards_cache_old; CREATE TABLE IF NOT EXISTS vcards_cache ( id INTEGER PRIMARY KEY AUTOINCREMENT, jid TEXT NOT NULL COLLATE NOCASE, data TEXT, timestamp INTEGER ); INSERT INTO vcards_cache ( jid, data, timestamp ) SELECT jid, data, timestamp FROM vcards_cache_old; DROP TABLE vcards_cache_old; CREATE INDEX IF NOT EXISTS vcards_cache_jid_idx on vcards_cache ( jid ); ALTER TABLE avatars_cache RENAME TO avatars_cache_old; CREATE TABLE IF NOT EXISTS avatars_cache ( id INTEGER PRIMARY KEY AUTOINCREMENT, jid TEXT NOT NULL COLLATE NOCASE, account TEXT NOT NULL COLLATE NOCASE, hash TEXT NOT NULL, type TEXT NOT NULL ); INSERT INTO avatars_cache ( jid, account, hash, type ) SELECT jid, account, hash, type FROM avatars_cache_old; DROP TABLE avatars_cache_old; CREATE INDEX IF NOT EXISTS avatars_cache_jid_idx on avatars_cache ( jid ); ================================================ FILE: SiskinIM/db-schema-3.sql ================================================ ALTER TABLE chats ADD COLUMN message_draft TEXT; ================================================ FILE: SiskinIM/db-schema-4.sql ================================================ ALTER TABLE chats ADD COLUMN name TEXT; ================================================ FILE: SiskinIM/db-schema-5.sql ================================================ CREATE TABLE IF NOT EXISTS omemo_sessions ( account TEXT NOT NULL COLLATE NOCASE, name TEXT NOT NULL, device_id INTEGER NOT NULL, key TEXT NOT NULL, UNIQUE (account, name, device_id) ON CONFLICT REPLACE ); CREATE TABLE IF NOT EXISTS omemo_identities ( account TEXT NOT NULL COLLATE NOCASE, name TEXT NOT NULL, device_id INTEGER NOT NULL, fingerprint TEXT NOT NULL, key BLOB NOT NULL, own INTEGER NOT NULL, status INTEGER NOT NULL, UNIQUE (account, name, fingerprint) ON CONFLICT IGNORE ); CREATE TABLE IF NOT EXISTS omemo_pre_keys ( account TEXT NOT NULL COLLATE NOCASE, id INTEGER NOT NULL, key BLOB NOT NULL, UNIQUE (account, id) ON CONFLICT REPLACE ); CREATE TABLE IF NOT EXISTS omemo_signed_pre_keys ( account TEXT NOT NULL COLLATE NOCASE, id INTEGER NOT NULL, key BLOB NOT NULL, UNIQUE (account, id) ON CONFLICT REPLACE ); ALTER TABLE chats ADD COLUMN encryption TEXT; ALTER TABLE chat_history ADD COLUMN encryption int; ALTER TABLE chat_history ADD COLUMN fingerprint text; ================================================ FILE: SiskinIM/db-schema-6.sql ================================================ ALTER TABLE chats ADD COLUMN options TEXT; ================================================ FILE: SiskinIM/db-schema-7.sql ================================================ UPDATE chat_history SET state = 11 WHERE state = 5; UPDATE chat_history SET state = 5 WHERE state = 9; UPDATE chat_history SET state = 9 WHERE state = 4; UPDATE chat_history SET state = 4 WHERE state = 7; UPDATE chat_history SET state = 7 WHERE state = 6; UPDATE chat_history SET state = 6 WHERE state = 8; ================================================ FILE: SiskinIM/db-schema-8.sql ================================================ CREATE TABLE IF NOT EXISTS chats_read ( account TEXT NOT NULL COLLATE NOCASE, jid TEXT NOT NULL COLLATE NOCASE, timestamp INTEGER, UNIQUE (account, jid) ); ================================================ FILE: SiskinIM/db-schema-9.sql ================================================ ALTER TABLE chat_history ADD COLUMN appendix TEXT; ================================================ FILE: SiskinIM/groupchat/InviteViewController.swift ================================================ // // InviteViewController.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class InviteViewController: AbstractRosterViewController { var room: Room!; var onNext: (([BareJID])->Void)? = nil; var selected: [BareJID] = []; override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); if onNext != nil { self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Create", comment: "button label"), style: .plain, target: self, action: #selector(selectionFinished(_:))) } else { self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel(_:))); } } @objc func cancel(_ sender: UIBarButtonItem) { self.dismiss(animated: true, completion: nil); } @objc func selectionFinished(_ sender: UIBarButtonItem) { self.dismiss(animated: true, completion: nil); if let onNext = self.onNext { self.onNext = nil; onNext(selected); } } public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let item = roster?.item(at: indexPath) else { return; } guard !tableView.allowsMultipleSelection else { if selected.contains(item.jid) { selected = selected.filter({ (jid) -> Bool in return jid != item.jid; }); tableView.deselectRow(at: indexPath, animated: true); } else { selected.append(item.jid); } return; } room.invite(JID(item.jid), reason: String.localizedStringWithFormat(NSLocalizedString("You are invied to join conversation at %@", comment: "error label"), room.roomJid.stringValue)); self.navigationController?.dismiss(animated: true, completion: nil); } override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { guard let item = roster?.item(at: indexPath) else { return; } selected = selected.filter({ (jid) -> Bool in return jid != item.jid; }); } } ================================================ FILE: SiskinIM/groupchat/MucChatOccupantsTableViewCell.swift ================================================ // // MucChatOccupantsTableViewCell.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine class MucChatOccupantsTableViewCell: UITableViewCell { static func labelViewFont() -> UIFont { let preferredFont = UIFont.preferredFont(forTextStyle: .subheadline); let fontDescription = preferredFont.fontDescriptor.withSymbolicTraits(.traitBold)!; return UIFont(descriptor: fontDescription, size: preferredFont.pointSize); } @IBOutlet var avatarStatusView: AvatarStatusView! @IBOutlet var nicknameLabel: UILabel! @IBOutlet var statusLabel: UILabel! override var backgroundColor: UIColor? { get { return super.backgroundColor; } set { super.backgroundColor = newValue; avatarStatusView?.backgroundColor = newValue; } } public static func roleToEmoji(_ role: MucRole) -> String { switch role { case .none, .visitor: return ""; case .participant: return "⭐"; case .moderator: return "🌟"; } } private var cancellables: Set = []; private var occupant: MucOccupant? { didSet { cancellables.removeAll(); if let occupant = occupant { // let nickname = occupant.nickname; nicknameLabel.text = occupant.nickname; occupant.$presence.map({ $0.show }).receive(on: DispatchQueue.main).assign(to: \.status, on: avatarStatusView).store(in: &cancellables); // occupant.$presence.map(XMucUserElement.extract(from: )).map({ $0?.role ?? .none }).map({ "\(nickname) \(MucChatOccupantsTableViewCell.roleToEmoji($0))" }).receive(on: DispatchQueue.main).assign(to: \.text, on: nicknameLabel).store(in: &cancellables); occupant.$presence.map({ $0.status }).receive(on: DispatchQueue.main).assign(to: \.text, on: statusLabel).store(in: &cancellables); } } } private var avatarObj: Avatar? { didSet { let name = self.nicknameLabel.text; avatarObj?.avatarPublisher.receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] image in self?.avatarStatusView.avatarImageView.set(name: name, avatar: image); }).store(in: &cancellables); } } override func awakeFromNib() { super.awakeFromNib() // Initialization code } func set(occupant: MucOccupant, in room: Room) { self.occupant = occupant; self.avatarObj = occupant.avatar; } } extension MucOccupant { var avatar: Avatar? { if let room = self.room { return AvatarManager.instance.avatarPublisher(for: .init(account: room.account, jid: room.jid, mucNickname: nickname)); } else { return nil; } } } ================================================ FILE: SiskinIM/groupchat/MucChatOccupantsTableViewController.swift ================================================ // // MucChatOccupantsTableViewController.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine class MucChatOccupantsTableViewController: UITableViewController { private class ParticipantsGroup: Equatable, Hashable { static func == (lhs: ParticipantsGroup, rhs: ParticipantsGroup) -> Bool { return lhs.role == rhs.role; } let role: MucRole; var participants: [MucOccupant]; func hash(into hasher: inout Hasher) { hasher.combine(role); } @available(iOS 13.0, *) var image: UIImage? { switch role { case .moderator: return UIImage(systemName: "rosette"); case .participant: return UIImage(systemName: "person.3"); case .visitor: return UIImage(systemName: "theatermasks"); case .none: return nil; } } var label: String { switch role { case .moderator: return NSLocalizedString("Moderators", comment: "list of users with this role"); case .participant: return NSLocalizedString("Participants", comment: "list of users with this role"); case .visitor: return NSLocalizedString("Visitors", comment: "list of users with this role"); case .none: return NSLocalizedString("None", comment: "list of users with this role"); } } var labelAttributedString: NSAttributedString { if #available(macOS 11.0, *) { let text = NSMutableAttributedString(string: ""); if let image = self.image { let att = NSTextAttachment(); att.image = image; text.append(NSAttributedString(attachment: att)); } text.append(NSAttributedString(string: self.label.uppercased())); return text; } else { return NSAttributedString(string: self.label); } } init(role: MucRole, participants: [MucOccupant] = []) { self.role = role; self.participants = participants; } } private var dispatcher = QueueDispatcher(label: "MucChatOccupantsTableViewController"); var room: Room! { didSet { cancellables.removeAll(); room.occupantsPublisher.throttle(for: 0.1, scheduler: self.dispatcher.queue, latest: true).sink(receiveValue: { [weak self] value in self?.update(participants: value); }).store(in: &cancellables); } } var mentionOccupant: ((String)->Void)? = nil; private var cancellables: Set = []; private let allGroups: [MucRole: ParticipantsGroup] = [ .moderator: ParticipantsGroup(role: .moderator), .participant: ParticipantsGroup(role: .participant), .visitor: ParticipantsGroup(role: .visitor) ]; private var groups: [ParticipantsGroup] = [ ]; private let allRoles: [MucRole] = [.moderator, .participant, .visitor]; override func viewDidLoad() { super.viewDidLoad() } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated); } private func update(participants: [MucOccupant]) { let oldGroups = self.groups; let newGroups = allRoles.map({ role in ParticipantsGroup(role: role, participants: participants.filter({ $0.role == role }).sorted(by: { (i1,i2) -> Bool in i1.nickname.lowercased() < i2.nickname.lowercased() })) }).filter({ !$0.participants.isEmpty }); let allChanges = newGroups.calculateChanges(from: oldGroups); // let allChanges2 = newGroups.compactMap({ newGroup -> (ParticipantsGroup,ParticipantsGroup)? in guard let oldGroup = oldGroups.first(where: { $0.role == newGroup.role }) else { return nil; } return (oldGroup, newGroup); }).map({ (old, new) in return new.participants.calculateChanges(from: old.participants); }) // let oldParticipants = self.participants; // let newParticipants = participants.sorted(by: { (i1,i2) -> Bool in i1.nickname.lowercased() < i2.nickname.lowercased() }); // let changes = newParticipants.calculateChanges(from: oldParticipants); DispatchQueue.main.sync { //self.groups = newGroups; self.groups = newGroups.map({ newGroup in let group = allGroups[newGroup.role]!; group.participants = newGroup.participants; return group; }) self.tableView?.beginUpdates(); if !allChanges.removed.isEmpty { tableView.deleteSections(allChanges.removed, with: .fade); } for (idx, changes) in allChanges2.enumerated() { self.tableView.deleteRows(at: changes.removed.map({ [idx, $0 ]}), with: .fade); self.tableView.insertRows(at: changes.inserted.map({ [idx, $0 ]}), with: .fade); } if !allChanges.inserted.isEmpty { tableView.insertSections(allChanges.inserted, with: .fade); } // self.tableView?.deleteRows(at: changes.removed.map({ IndexPath(row: $0, section: 0)}), with: .fade); // self.tableView?.insertRows(at: changes.inserted.map({ IndexPath(row: $0, section: 0)}), with: .fade); self.tableView?.endUpdates(); self.tableView?.isHidden = false; } } // MARK: - Table view data source override func numberOfSections(in: UITableView) -> Int { return groups.count; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return groups[section].participants.count; } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { return groups[section].label; } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { return nil; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "MucChatOccupantsTableViewCell", for: indexPath as IndexPath) as! MucChatOccupantsTableViewCell; let occupant = groups[indexPath.section].participants[indexPath.row]; cell.set(occupant: occupant, in: self.room); return cell } override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { return false; } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true); let occupant = groups[indexPath.section].participants[indexPath.row]; if let fn = mentionOccupant { fn(occupant.nickname); } self.navigationController?.popViewController(animated: true); } override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard room.state == .joined else { return nil; } let participant = groups[indexPath.section].participants[indexPath.row]; return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { suggestedActions in var actions: [UIAction] = []; actions.append(UIAction(title: NSLocalizedString("Private message", comment: "action label"), handler: { action in let alert = UIAlertController(title: NSLocalizedString("Send message", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Enter message to send to: %@", comment: "alert body"), participant.nickname), preferredStyle: .alert); alert.addTextField(configurationHandler: nil); alert.addAction(UIAlertAction(title: NSLocalizedString("Send", comment: "button label"), style: .default, handler: { action in guard let text = alert.textFields?.first?.text else { return; } self.room.sendPrivateMessage(to: participant, text: text); })); alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); self.present(alert, animated: true, completion: nil); })); if let jid = participant.jid, self.room.affiliation == MucAffiliation.admin { actions.append(UIAction(title: NSLocalizedString("Ban user", comment: "action label"), handler: { action in guard let mucModule = self.room.context?.module(.muc) else { return; } let alert = UIAlertController(title: NSLocalizedString("Banning user", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Do you want to ban user %@?", comment: "alert body"), participant.nickname), preferredStyle: .alert); alert.addAction(UIAlertAction(title: "Yes", style: .destructive, handler: { action in mucModule.setRoomAffiliations(to: self.room, changedAffiliations: [MucModule.RoomAffiliation(jid: jid, affiliation: .outcast)], completionHandler: { result in switch result { case .success(_): break; case .failure(let error): DispatchQueue.main.async { let alert = UIAlertController(title: String.localizedStringWithFormat(NSLocalizedString("Banning user %@ failed", comment: "alert title"), participant.nickname), message: String.localizedStringWithFormat(NSLocalizedString("Server returned an error: %@", comment: "alert body"), error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .cancel, handler: nil)); self.present(alert, animated: true, completion: nil); } } }); })) alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); self.present(alert, animated: true, completion: nil); })); } return UIMenu(title: "", children: actions); }); } /* // Override to support editing the table view. override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) { if editingStyle == .Delete { // Delete the row from the data source tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Fade) } else if editingStyle == .Insert { // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view } } */ override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if let invitationController = segue.destination as? InviteViewController ?? (segue.destination as? UINavigationController)?.visibleViewController as? InviteViewController { invitationController.room = self.room; } } } ================================================ FILE: SiskinIM/groupchat/MucChatSettingsViewController.swift ================================================ // // MucChatSettingsViewController.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine import Shared class MucChatSettingsViewController: UITableViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate { @IBOutlet var roomNameField: UILabel!; @IBOutlet var roomAvatarView: AvatarView! @IBOutlet var roomSubjectField: UILabel!; @IBOutlet var pushNotificationsSwitch: UISwitch!; @IBOutlet var notificationsField: UILabel!; @IBOutlet var encryptionField: UILabel!; fileprivate var activityIndicator: UIActivityIndicatorView?; var room: Room!; @Published private var canEditVCard: Bool = false; private var cancellables: Set = []; override func viewWillAppear(_ animated: Bool) { roomAvatarView.layer.cornerRadius = roomAvatarView.frame.width / 2; roomAvatarView.layer.masksToBounds = true; // roomAvatarView.widthAnchor.constraint(equalTo: roomAvatarView.heightAnchor).isActive = true; room.optionsPublisher.compactMap({ $0.name }).receive(on: DispatchQueue.main).assign(to: \.text, on: roomNameField).store(in: &cancellables); room.avatarPublisher.map({ $0 ?? AvatarManager.instance.defaultGroupchatAvatar }).receive(on: DispatchQueue.main).assign(to: \.avatar, on: roomAvatarView).store(in: &cancellables); room.descriptionPublisher.receive(on: DispatchQueue.main).assign(to: \.text, on: roomSubjectField).store(in: &cancellables); self.navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "pencil.circle"), style: .plain, target: self, action: #selector(MucChatSettingsViewController.editClicked(_:))); // room.$affiliation.map({ $0 == .admin || $0 == .owner }).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] value in // guard let that = self else { // return; // } // that.navigationItem.rightBarButtonItem = value ? UIBarButtonItem(barButtonSystemItem: .edit, target: that, action: #selector(MucChatSettingsViewController.editClicked(_:))) : nil; // }).store(in: &cancellables); pushNotificationsSwitch.isEnabled = false; pushNotificationsSwitch.isOn = false; room.optionsPublisher.map({ $0.encryption?.description ?? NSLocalizedString("Default", comment: "encryption setting value") }).receive(on: DispatchQueue.main).assign(to: \.text, on: encryptionField).store(in: &cancellables); room.optionsPublisher.map({ MucChatSettingsViewController.labelFor(conversationNotification: $0.notifications) }).receive(on: DispatchQueue.main).assign(to: \.text, on: notificationsField).store(in: &cancellables); refresh(); if #available(iOS 14.0, *) { if let pepBookmarksModule = room.context?.module(.pepBookmarks) { room.$affiliation.map({ $0 == .admin || $0 == .owner }).combineLatest($canEditVCard, pepBookmarksModule.$currentBookmarks).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] (value, canEditVCard, bookmarks) in guard let that = self else { return; } that.navigationItem.rightBarButtonItem?.target = nil; that.navigationItem.rightBarButtonItem?.action = nil; that.navigationItem.rightBarButtonItem?.primaryAction = nil that.navigationItem.rightBarButtonItem?.menu = that.prepareEditContextMenu(isOwner: value, canEditVCard: canEditVCard, bookmarks: bookmarks); }).store(in: &cancellables); } } } @available(iOS 14.0, *) private func prepareEditContextMenu(isOwner: Bool, canEditVCard: Bool, bookmarks: Bookmarks) -> UIMenu { var actions: [UIMenuElement] = []; if let pepBookmarksModule = room.context?.module(.pepBookmarks), let room = self.room, room.context?.isConnected ?? false { if let bookmark = bookmarks.conference(for: JID(room.jid)) { actions.append(UIAction(title: NSLocalizedString("Remove bookmark", comment: "button label"), image: UIImage(systemName: "bookmark.slash"), handler: { action in pepBookmarksModule.remove(bookmark: bookmark); })); } else { actions.append(UIAction(title: NSLocalizedString("Create bookmark", comment: "button label"), image: UIImage(systemName: "bookmark"), handler: { action in pepBookmarksModule.addOrUpdate(bookmark: Bookmarks.Conference(name: room.name ?? room.jid.localPart ?? room.jid.stringValue, jid: JID(room.jid), autojoin: false, nick: room.nickname, password: room.password)); })); } } actions.append(UIAction(title: NSLocalizedString("Rename chat", comment: "button label"), handler: { action in self.renameChat(); })); if canEditVCard { if UIImagePickerController.isSourceTypeAvailable(.camera) { actions.append(UIMenu(title: NSLocalizedString("Change avatar", comment: "button label"), children: [ UIAction(title: NSLocalizedString("Take photo", comment: "button label"), handler: { action in self.selectPhoto(.camera); }), UIAction(title: NSLocalizedString("Select photo", comment: "button label"), handler: { action in self.selectPhoto(.photoLibrary); }) ])); } else { actions.append(UIAction(title: NSLocalizedString("Change avatar", comment: "button label"), handler: { action in self.selectPhoto(.photoLibrary); })); } } actions.append(UIAction(title: NSLocalizedString("Change subject", comment: "button label"), handler: { action in self.changeSubject(); })); return UIMenu(title: "", children: actions); } override func viewDidDisappear(_ animated: Bool) { cancellables.removeAll(); super.viewDidDisappear(animated); } @objc func dismissView() { self.dismiss(animated: true, completion: nil); } func refresh() { guard room.state == .joined, let context = room.context else { return; } showIndicator(); let dispatchGroup = DispatchGroup(); dispatchGroup.enter(); context.module(.disco).getInfo(for: JID(room.jid), completionHandler: { result in DispatchQueue.main.async { switch result { case .success(let info): self.pushNotificationsSwitch.isEnabled = (context.module(.push) as! SiskinPushNotificationsModule).isEnabled && info.features.contains("jabber:iq:register"); if self.pushNotificationsSwitch.isEnabled { self.room.checkTigasePushNotificationRegistrationStatus(completionHandler: { (result) in switch result { case .failure(_): DispatchQueue.main.async { self.pushNotificationsSwitch.isEnabled = false; dispatchGroup.leave(); } case .success(let value): DispatchQueue.main.async { self.pushNotificationsSwitch.isOn = value; dispatchGroup.leave(); } } }) } else { dispatchGroup.leave(); } case .failure(_): self.pushNotificationsSwitch.isEnabled = false; dispatchGroup.leave(); } } }); dispatchGroup.enter(); context.module(.vcardTemp).retrieveVCard(from: JID(room.jid), completionHandler: { (result) in switch result { case .success(let vcard): DBVCardStore.instance.updateVCard(for: self.room.roomJid, on: self.room.account, vcard: vcard); DispatchQueue.main.async { self.canEditVCard = true; dispatchGroup.leave(); } case .failure(_): DispatchQueue.main.async { self.canEditVCard = false; dispatchGroup.leave(); } } }) dispatchGroup.notify(queue: DispatchQueue.main, execute: self.hideIndicator); } @IBAction func pushNotificationSwitchChanged(_ sender: UISwitch) { self.room.registerForTigasePushNotification(sender.isOn) { (result) in switch result { case .failure(_): DispatchQueue.main.async { sender.isOn = !sender.isOn; } case .success(_): // nothing to do.. break; } } } func showIndicator() { if activityIndicator != nil { hideIndicator(); } activityIndicator = UIActivityIndicatorView(style: .medium); activityIndicator?.center = CGPoint(x: view.frame.width/2, y: view.frame.height/2); activityIndicator!.isHidden = false; activityIndicator!.startAnimating(); view.addSubview(activityIndicator!); view.bringSubviewToFront(activityIndicator!); } func hideIndicator() { activityIndicator?.stopAnimating(); activityIndicator?.removeFromSuperview(); activityIndicator = nil; } override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { if indexPath.section == 2 && indexPath.row == 1 && !room.features.contains(.omemo) { return 0; } return super.tableView(tableView, heightForRowAt: indexPath); } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true); if indexPath.section == 2 { if indexPath.row == 0 { let controller = TablePickerViewController(options: [.always, .mention, .none], value: room.notifications, labelFn: MucChatSettingsViewController.labelFor(conversationNotification: )); controller.sink(receiveValue: { [weak self] value in guard let room = self?.room else { return; } room.updateOptions({ (options) in options.notifications = value; }, completionHandler: { if let pushModule = (room.context?.module(.push) as? SiskinPushNotificationsModule), let pushSettings = pushModule.pushSettings { pushModule.reenable(pushSettings: pushSettings, completionHandler: { result in switch result { case .success(_): break; case .failure(_): AccountSettings.pushHash(for: room.account, value: 0); } }); } }); }); self.navigationController?.pushViewController(controller, animated: true); } if indexPath.row == 1 { let controller = TablePickerViewController(options: [.none, .omemo], value: room.options.encryption ?? .none); controller.sink(receiveValue: { value in self.room.updateOptions({ (options) in options.encryption = value; }, completionHandler: nil); }); self.navigationController?.pushViewController(controller, animated: true); } } } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == "chatShowAttachments" { if let attachmentsController = segue.destination as? ChatAttachmentsController { attachmentsController.conversation = self.room; } } } @objc func editClicked(_ sender: UIBarButtonItem) { let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet); if room.context?.isConnected ?? false, let pepBookmarksModule = room.context?.module(.pepBookmarks) { if pepBookmarksModule.currentBookmarks.conference(for: JID(room.jid)) == nil { alertController.addAction(UIAlertAction(title: NSLocalizedString("Create bookmark", comment: "button label"), style: .default, handler: { action in pepBookmarksModule.addOrUpdate(bookmark: Bookmarks.Conference(name: self.room.name, jid: JID(self.room.jid), autojoin: false, nick: self.room.nickname, password: self.room.password)); })) } else { alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove bookmark", comment: "button label"), style: .default, handler: { action in pepBookmarksModule.remove(bookmark: Bookmarks.Conference(name: self.room.name, jid: JID(self.room.jid), autojoin: false)); })) } } alertController.addAction(UIAlertAction(title: NSLocalizedString("Rename chat", comment: "button label"), style: .default, handler: { (action) in self.renameChat(); })); if canEditVCard { alertController.addAction(UIAlertAction(title: NSLocalizedString("Change avatar", comment: "button label"), style: .default, handler: { (action) in if UIImagePickerController.isSourceTypeAvailable(.camera) { let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet); alert.addAction(UIAlertAction(title: NSLocalizedString("Take photo", comment: "button label"), style: .default, handler: { (action) in self.selectPhoto(.camera); })); alert.addAction(UIAlertAction(title: NSLocalizedString("Select photo", comment: "button label"), style: .default, handler: { (action) in self.selectPhoto(.photoLibrary); })); alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); alert.popoverPresentationController?.barButtonItem = sender; self.present(alert, animated: true, completion: nil); } else { self.selectPhoto(.photoLibrary); } })); } alertController.addAction(UIAlertAction(title: NSLocalizedString("Change subject", comment: "button label"), style: .default, handler: { (action) in self.changeSubject(); })); alertController.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); alertController.popoverPresentationController?.barButtonItem = sender; self.present(alertController, animated: true, completion: nil); } private func selectPhoto(_ source: UIImagePickerController.SourceType) { let picker = UIImagePickerController(); picker.delegate = self; picker.allowsEditing = true; picker.sourceType = source; present(picker, animated: true, completion: nil); } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { guard var photo = (info[UIImagePickerController.InfoKey.editedImage] as? UIImage) else { return; } self.showIndicator(); // scalling photo to max of 180px var size: CGSize! = nil; if photo.size.height > photo.size.width { size = CGSize(width: (photo.size.width/photo.size.height) * 180, height: 180); } else { size = CGSize(width: 180, height: (photo.size.height/photo.size.width) * 180); } UIGraphicsBeginImageContextWithOptions(size, false, 0); photo.draw(in: CGRect(x: 0, y: 0, width: size.width, height: size.height)); photo = UIGraphicsGetImageFromCurrentImageContext()!; UIGraphicsEndImageContext(); // saving photo guard let data = photo.jpegData(compressionQuality: 0.8) else { self.hideIndicator(); return; } picker.dismiss(animated: true, completion: nil); guard let vcardTempModule = room.context?.module(.vcardTemp) else { hideIndicator(); return; } let vcard = VCard(); vcard.photos = [VCard.Photo(uri: nil, type: "image/jpeg", binval: data.base64EncodedString(), types: [.home])]; vcardTempModule.publishVCard(vcard, to: room.roomJid, completionHandler: { result in switch result { case .success(_): DispatchQueue.main.async { self.roomAvatarView.image = self.squared(image: photo); self.hideIndicator(); } case .failure(let errorCondition): DispatchQueue.main.async { self.hideIndicator(); self.showError(title: NSLocalizedString("Error", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Could not set group chat avatar. The server responded with an error: %@", comment: "alert body"), errorCondition.localizedDescription)); } } }); } private func renameChat() { let controller = UIAlertController(title: NSLocalizedString("Rename chat", comment: "alert title"), message: NSLocalizedString("Enter new name for group chat", comment: "alert body"), preferredStyle: .alert); controller.addTextField { (textField) in textField.text = self.room.name ?? ""; } let nameField = controller.textFields![0]; controller.addAction(UIAlertAction(title: NSLocalizedString("Rename", comment: "button label"), style: .default, handler: { (action) in let newName = nameField.text; guard let mucModule = self.room.context?.module(.muc) else { return; } self.showIndicator(); mucModule.getRoomConfiguration(roomJid: JID(self.room.jid), completionHandler: { result in switch result { case .success(let form): (form.getField(named: "muc#roomconfig_roomname") as? TextSingleField)?.value = newName; mucModule.setRoomConfiguration(roomJid: JID(self.room.jid), configuration: form, completionHandler: { result in DispatchQueue.main.async { self.hideIndicator(); switch result { case .success(_): self.roomNameField.text = nameField.text; case .failure(let error): self.showError(title: NSLocalizedString("Error", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Could not rename group chat. The server responded with an error: %@", comment: "alert body"), error.localizedDescription)) } } }); case .failure(let error): self.hideIndicator(); self.showError(title: NSLocalizedString("Error", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Could not rename group chat. The server responded with an error: %@", comment: "alert body"), error.localizedDescription)) } }); })) controller.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); self.present(controller, animated: true, completion: nil); } private func changeSubject() { let controller = UIAlertController(title: NSLocalizedString("Change subject", comment: "alert title"), message: NSLocalizedString("Enter new subject for group chat", comment: "alert body"), preferredStyle: .alert); controller.addTextField { (textField) in textField.text = self.room.subject ?? ""; } let subjectField = controller.textFields![0]; controller.addAction(UIAlertAction(title: NSLocalizedString("Change", comment: "button label"), style: .default, handler: { [weak self] (action) in guard let room = self?.room, let mucModule = self?.room.context?.module(.muc) else { return; } mucModule.setRoomSubject(roomJid: room.roomJid, newSubject: subjectField.text); self?.roomSubjectField.text = subjectField.text; })); controller.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); self.present(controller, animated: true, completion: nil); } private func showError(title: String, message: String) { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .cancel, handler: nil)); self.present(alert, animated: true, completion: nil); } private func squared(image inImage: UIImage?) -> UIImage? { guard let image = inImage else { return nil; } let origSize = image.size; guard origSize.width != origSize.height else { return image; } let size = min(origSize.width, origSize.height); let x = origSize.width > origSize.height ? ((origSize.width - origSize.height)/2) : 0.0; let y = origSize.width > origSize.height ? 0.0 : ((origSize.height - origSize.width)/2); UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 0); image.draw(in: CGRect(x: x * (-1.0), y: y * (-1.0), width: origSize.width, height: origSize.height)); let squared = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return squared; } static func labelFor(conversationNotification type: ConversationNotification) -> String { switch type { case .none: return NSLocalizedString("Muted", comment: "conversation notifications status"); case .mention: return NSLocalizedString("When mentioned", comment: "conversation notifications status"); case .always: return NSLocalizedString("Always", comment: "conversation notifications status"); } } } ================================================ FILE: SiskinIM/groupchat/MucChatViewController.swift ================================================ // // MucChatViewController.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import MartinOMEMO import Combine class MucChatViewController: BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar { static let MENTION_OCCUPANT = Notification.Name("groupchatMentionOccupant"); var titleView: MucTitleView! { get { return self.navigationItem.titleView as? MucTitleView; } } var room: Room { return self.conversation as! Room; } private var cancellables: Set = []; override func viewDidLoad() { super.viewDidLoad() let recognizer = UITapGestureRecognizer(target: self, action: #selector(MucChatViewController.roomInfoClicked)); self.titleView?.isUserInteractionEnabled = true; self.navigationController?.navigationBar.addGestureRecognizer(recognizer); initializeSharing(); } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); room.context!.$state.map({ $0 == .connected() }).combineLatest(room.$state).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] (connected, state) in self?.titleView?.refresh(connected: connected, state: state); self?.navigationItem.rightBarButtonItem?.isEnabled = state == .joined; }).store(in: &cancellables); room.displayNamePublisher.map({ $0 }).assign(to: \.name, on: self.titleView).store(in: &cancellables); } override func viewDidDisappear(_ animated: Bool) { cancellables.removeAll(); super.viewDidDisappear(animated); } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } override func canExecuteContext(action: BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar.ContextAction, forItem item: ConversationEntry, at indexPath: IndexPath) -> Bool { switch action { case .retract: return item.state.direction == .outgoing && room.context?.state == .connected() && room.state == .joined; case .moderate: return item.state.direction == .incoming && item.payload != .messageRetracted && room.context?.state == .connected() && room.state == .joined && room.role == .moderator && room.roomFeatures.contains(.messageModeration); default: return super.canExecuteContext(action: action, forItem: item, at: indexPath); } } override func executeContext(action: BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar.ContextAction, forItem item: ConversationEntry, at indexPath: IndexPath) { switch action { case .retract: guard item.state.direction == .outgoing else { return; } room.retract(entry: item); case .moderate: room.moderate(entry: item, completionHandler: { result in switch result { case .failure(let error): DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Failure", comment: "alert title"), message: NSLocalizedString("Message moderation failed!", comment: "alert body"), preferredStyle: .alert); alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)); self.present(alert, animated: true, completion: nil); } case .success(_): break; } }); default: super.executeContext(action: action, forItem: item, at: indexPath); } } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == "showOccupants" { if let navigation = segue.destination as? UINavigationController { if let occupantsController = navigation.visibleViewController as? MucChatOccupantsTableViewController { occupantsController.room = room; occupantsController.mentionOccupant = { [weak self] name in var text = self?.messageText ?? ""; if text.last != " " { text = text + " "; } self?.messageText = "\(text)@\(name) "; } } } else { if let occupantsController = segue.destination as? MucChatOccupantsTableViewController { occupantsController.room = room; occupantsController.mentionOccupant = { [weak self] name in var text = self?.messageText ?? ""; if text.last != " " { text = text + " "; } self?.messageText = "\(text)@\(name) "; } } } } super.prepare(for: segue, sender: sender); } @IBAction func sendClicked(_ sender: UIButton) { self.sendMessage(); } override func sendMessage() { guard let text = messageText, !text.isEmpty else { return; } guard room.state == .joined else { let alert = UIAlertController.init(title: NSLocalizedString("Warning", comment: "alert title"), message: NSLocalizedString("You are not connected to room.\nPlease wait reconnection to room", comment: "alert body"), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); self.present(alert, animated: true, completion: nil); return; } let canEncrypt = room.features.contains(.omemo); let encryption: ChatEncryption = room.options.encryption ?? (canEncrypt ? Settings.messageEncryption : .none); guard encryption == .none || canEncrypt else { if encryption == .omemo && !canEncrypt { let alert = UIAlertController(title: NSLocalizedString("Warning", comment: "alert title"), message: NSLocalizedString("This room is not capable of sending encrypted messages. Please change encryption settings to be able to send messages", comment: "alert body"), preferredStyle: .alert); alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)); self.present(alert, animated: true, completion: nil); } return; } room.sendMessage(text: text, correctedMessageOriginId: correctedMessageOriginId); DispatchQueue.main.async { self.messageText = nil; } } override func sendAttachment(originalUrl: URL?, uploadedUrl: String, appendix: ChatAttachmentAppendix, completionHandler: (() -> Void)?) { let canEncrypt = room.features.contains(.omemo); let encryption: ChatEncryption = room.options.encryption ?? (canEncrypt ? Settings.messageEncryption : .none); guard encryption == .none || canEncrypt else { completionHandler?(); return; } room.sendAttachment(url: uploadedUrl, appendix: appendix, originalUrl: originalUrl, completionHandler: completionHandler); } @objc func roomInfoClicked() { guard let settingsController = self.storyboard?.instantiateViewController(withIdentifier: "MucChatSettingsViewController") as? MucChatSettingsViewController else { return; } settingsController.room = self.room; let navigation = UINavigationController(rootViewController: settingsController); navigation.title = self.title; navigation.modalPresentationStyle = .formSheet; settingsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: settingsController, action: #selector(MucChatSettingsViewController.dismissView)); self.present(navigation, animated: true, completion: nil); //self.navigationController?.pushViewController(settingsController, animated: true); } } class MucTitleView: UIView { @IBOutlet var nameView: UILabel!; @IBOutlet var statusView: UILabel!; var name: String? { get { return nameView.text; } set { nameView.text = newValue; } } override var intrinsicContentSize: CGSize { return UIView.layoutFittingExpandedSize } override func didMoveToSuperview() { super.didMoveToSuperview(); if let superview = self.superview { NSLayoutConstraint.activate([ self.widthAnchor.constraint(lessThanOrEqualTo: superview.widthAnchor, multiplier: 0.6)]); } } func refresh(connected: Bool, state: RoomState) { if connected { let statusIcon = NSTextAttachment(); var show: Presence.Show?; var desc = NSLocalizedString("Offline", comment: "muc room status"); switch state { case .joined: show = Presence.Show.online; desc = NSLocalizedString("Online", comment: "muc room status"); case .requested: show = Presence.Show.away; desc = NSLocalizedString("Joining…", comment: "muc room status"); default: break; } statusIcon.image = AvatarStatusView.getStatusImage(show); let height = statusView.font.pointSize; statusIcon.bounds = CGRect(x: 0, y: -2, width: height, height: height); let statusText = NSMutableAttributedString(attributedString: NSAttributedString(attachment: statusIcon)); statusText.append(NSAttributedString(string: desc)); statusView.attributedText = statusText; } else { statusView.text = "\u{26A0} \(NSLocalizedString("Not connected", comment: "muc room status label"))!"; } } } ================================================ FILE: SiskinIM/localization/Base.lproj/Account.storyboard ================================================ ================================================ FILE: SiskinIM/localization/Base.lproj/Conversation.storyboard ================================================ ================================================ FILE: SiskinIM/localization/Base.lproj/Groupchat.storyboard ================================================ Push notification support depends on the XMPP server which you are using and may not work in some cases even if it's supported and enabled on the group chat ================================================ FILE: SiskinIM/localization/Base.lproj/Info.storyboard ================================================ ============================ Siskin IM by Tigase, Inc. Copyright (C) 2019 "Tigase, Inc." <office@tigase.com> This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/. ============================ Copyright (c) 2011, The WebRTC project authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ============================ Additional IP Rights Grant (Patents) "This implementation" means the copyrightable works distributed by Google as part of the WebRTC code package. Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, transfer, and otherwise run, modify and propagate the contents of this implementation of the WebRTC code package, where such license applies only to those patent claims, both currently owned by Google and acquired in the future, licensable by Google that are necessarily infringed by this implementation of the WebRTC code package. This grant does not include claims that would be infringed only as a consequence of further modification of this implementation. If you or your agent or exclusive licensee institute or order or agree to the institution of patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that this implementation of the WebRTC code package or any code incorporated within this implementation of the WebRTC code package constitutes direct or contributory patent infringement, or inducement of patent infringement, then any patent rights granted to you under this License for this implementation of the WebRTC code package shall terminate as of the date such litigation is filed. ================================================ FILE: SiskinIM/localization/Base.lproj/LaunchScreen.storyboard ================================================ ================================================ FILE: SiskinIM/localization/Base.lproj/MIX.storyboard ================================================ Notification support and filtering depends on the XMPP server which you are using and may not work in some cases even if it's enabled here. ================================================ FILE: SiskinIM/localization/Base.lproj/Main.storyboard ================================================ ================================================ FILE: SiskinIM/localization/Base.lproj/Settings.storyboard ================================================ ================================================ FILE: SiskinIM/localization/Base.lproj/VoIP.storyboard ================================================ ================================================ FILE: SiskinIM/localization/de.lproj/Account.strings ================================================ /* Class = "UILabel"; text = "add email"; ObjectID = "06Y-Cg-0Q3"; */ "06Y-Cg-0Q3.text" = "E-Mail hinzufügen"; /* Class = "UILabel"; text = "Nickname"; ObjectID = "27D-rn-4zp"; */ "27D-rn-4zp.text" = "Nickname"; /* Class = "UITextField"; placeholder = "Country"; ObjectID = "4hs-GL-9fd"; */ "4hs-GL-9fd.placeholder" = "Land"; /* Class = "UILabel"; text = "Host"; ObjectID = "5fg-qS-fyV"; */ "5fg-qS-fyV.text" = "Host"; /* Class = "UILabel"; text = "Change account settings"; ObjectID = "70p-aF-3x5"; */ "70p-aF-3x5.text" = "Kontoeinstellungen ändern"; /* Class = "UILabel"; text = "Scan QR Code to add me as a buddy"; ObjectID = "7cH-MH-cya"; */ "7cH-MH-cya.text" = "QR-Code scannen, um mich als Freund hinzuzufügen"; /* Class = "UITextField"; placeholder = "Required"; ObjectID = "7KN-S3-8XR"; */ "7KN-S3-8XR.placeholder" = "Erforderlich"; /* Class = "UILabel"; text = "From last"; ObjectID = "7td-ty-azW"; */ "7td-ty-azW.text" = "Letztes Mal"; /* Class = "UITableViewSection"; headerTitle = "Message Archiving"; ObjectID = "9PW-Rb-rop"; */ "9PW-Rb-rop.headerTitle" = "Nachrichtenarchivierung"; /* Class = "UITableViewController"; title = "OMEMO fingerprints"; ObjectID = "A24-eF-tzh"; */ "A24-eF-tzh.title" = "OMEMO-Fingerabdrücke"; /* Class = "UILabel"; text = "When in Away/XA/DND state"; ObjectID = "aKW-CM-bl2"; */ "aKW-CM-bl2.text" = "Im Status Abwesend/Länger weg/Nicht stören"; /* Class = "UITextField"; placeholder = "Type"; ObjectID = "Clb-vT-t5A"; Note = "Placeholder text for selection of address/phone type (home/work)"; */ "Clb-vT-t5A.placeholder" = "Type"; /* Class = "UILabel"; text = "Port"; ObjectID = "clx-xZ-SVL"; */ "clx-xZ-SVL.text" = "Port"; /* Class = "UITextField"; placeholder = "City"; ObjectID = "Cmz-iN-c4d"; */ "Cmz-iN-c4d.placeholder" = "Stadt"; /* Class = "UILabel"; text = "Use Direct TLS"; ObjectID = "dtA-fp-y9J"; */ "dtA-fp-y9J.text" = "Direct TLS verwenden"; /* Class = "UILabel"; text = "Month"; ObjectID = "E11-00-jW5"; */ "E11-00-jW5.text" = "Monat"; /* Class = "UINavigationItem"; title = "Settings"; ObjectID = "eIR-sl-fh5"; */ "eIR-sl-fh5.title" = "Einstellungen"; /* Class = "UITableViewSection"; headerTitle = "Encryption"; ObjectID = "Et5-H6-t7C"; */ "Et5-H6-t7C.headerTitle" = "Verschlüsselung"; /* Class = "UITextField"; placeholder = "Automatic"; ObjectID = "FBK-JI-crl"; */ "FBK-JI-crl.placeholder" = "Automatisch"; /* Class = "UILabel"; text = "add address"; ObjectID = "fRX-fN-yOj"; */ "fRX-fN-yOj.text" = "Adresse hinzufügen"; /* Class = "UILabel"; text = "Enabled"; ObjectID = "GTa-Ee-HQT"; */ "GTa-Ee-HQT.text" = "Aktiviert"; /* Class = "UILabel"; text = "OMEMO fingerprint"; ObjectID = "gUr-GY-P3A"; */ "gUr-GY-P3A.text" = "OMEMO-Fingerabdruck"; /* Class = "UILabel"; text = "Delete"; ObjectID = "GWw-NK-6oU"; */ "GWw-NK-6oU.text" = "Löschen"; /* Class = "UINavigationItem"; title = "Account settings"; ObjectID = "gzC-dB-kIF"; */ "gzC-dB-kIF.title" = "Kontoeinstellungen"; /* Class = "UIBarButtonItem"; title = "Done"; ObjectID = "ijD-Kb-uyj"; */ "ijD-Kb-uyj.title" = "Fertig"; /* Class = "UILabel"; text = "add phone"; ObjectID = "jAE-vq-Vfj"; */ "jAE-vq-Vfj.text" = "Telefon hinzufügen"; /* Class = "UITextField"; placeholder = "Street"; ObjectID = "KgL-GY-IFp"; */ "KgL-GY-IFp.placeholder" = "Straße"; /* Class = "UILabel"; text = "Advanced"; ObjectID = "KjA-5f-Mg4"; */ "KjA-5f-Mg4.text" = "Erweitert"; /* Class = "UITableViewSection"; headerTitle = "Password"; ObjectID = "LO4-Ys-cek"; */ "LO4-Ys-cek.headerTitle" = "Passwort"; /* Class = "UIButton"; normalTitle = "Change avatar"; ObjectID = "Mo3-sc-7ss"; */ "Mo3-sc-7ss.normalTitle" = "Avatar ändern"; /* Class = "UITextField"; placeholder = "Type"; ObjectID = "NK4-tE-QSu"; Note = "Placeholder text for selection of address/phone type (home/work)"; */ "NK4-tE-QSu.placeholder" = "Type"; /* Class = "UITextField"; placeholder = "Code"; ObjectID = "NUu-KT-QM5"; Note = "Postal Code"; */ "NUu-KT-QM5.placeholder" = "Code"; /* Class = "UILabel"; text = "Enable"; ObjectID = "P6B-h8-PVB"; */ "P6B-h8-PVB.text" = "Aktivieren"; /* Class = "UITableViewSection"; footerTitle = "Receive push notifications when your other clients are connected but in Away/XA/DND state"; ObjectID = "PvC-LX-0Sp"; */ "PvC-LX-0Sp.footerTitle" = "Push-Benachrichtigungen erhalten, wenn andere Geräte verbunden sind, sich aber im Status Abwesend/Länger weg/Nicht stören befinden"; /* Class = "UITableViewSection"; headerTitle = "Push Notifications"; ObjectID = "PvC-LX-0Sp"; */ "PvC-LX-0Sp.headerTitle" = "Push-Benachrichtigungen"; /* Class = "UITableViewSection"; headerTitle = "Encryption"; ObjectID = "qGc-1C-qdh"; */ "qGc-1C-qdh.headerTitle" = "Verschlüsselung"; /* Class = "UITextField"; placeholder = "Automatic"; ObjectID = "QX9-Nr-Eq6"; */ "QX9-Nr-Eq6.placeholder" = "Automatisch"; /* Class = "UIBarButtonItem"; title = "Next"; ObjectID = "R0m-k2-q64"; */ "R0m-k2-q64.title" = "Weiter"; /* Class = "UIBarButtonItem"; title = "Skip"; ObjectID = "Rto-c0-DcC"; */ "Rto-c0-DcC.title" = "Überspringen"; /* Class = "UITableViewSection"; footerTitle = "Enter your account JID"; ObjectID = "SKG-bP-NPK"; */ "SKG-bP-NPK.footerTitle" = "Gib deine Konto-JID ein"; /* Class = "UITableViewSection"; headerTitle = "XMPP ID"; ObjectID = "SKG-bP-NPK"; */ "SKG-bP-NPK.headerTitle" = "XMPP-ID"; /* Class = "UITextField"; placeholder = "Phone number"; ObjectID = "T1r-DU-iqs"; */ "T1r-DU-iqs.placeholder" = "Telefonnummer"; /* Class = "UILabel"; text = "Server features"; ObjectID = "t3T-uh-mob"; */ "t3T-uh-mob.text" = "Serverfunktionen"; /* Class = "UITextField"; placeholder = "Type"; ObjectID = "UQ1-rc-tuY"; Note = "Placeholder text for selection of address/phone type (home/work)"; */ "UQ1-rc-tuY.placeholder" = "Type"; /* Class = "UITableViewSection"; headerTitle = "General"; ObjectID = "v8B-ee-zAk"; */ "v8B-ee-zAk.headerTitle" = "Allgemein"; /* Class = "UILabel"; text = "Enabled"; ObjectID = "wbI-5v-vug"; */ "wbI-5v-vug.text" = "Aktiviert"; /* Class = "UITableViewSection"; headerTitle = "Connectivity"; ObjectID = "we8-hg-uOZ"; */ "we8-hg-uOZ.headerTitle" = "Verbindungen"; /* Class = "UITableViewController"; title = "Server Features"; ObjectID = "wW7-3f-Kck"; */ "wW7-3f-Kck.title" = "Serverfunktionen"; /* Class = "UILabel"; text = "Title"; ObjectID = "XMr-e2-2o3"; */ "XMr-e2-2o3.text" = "Titel"; /* Class = "UILabel"; text = "Disable TLS 1.3"; ObjectID = "xMW-iz-ghG"; */ "xMW-iz-ghG.text" = "TLS 1.3 deaktivieren"; /* Class = "UITextField"; placeholder = "Email address"; ObjectID = "ynp-RE-XlY"; */ "ynp-RE-XlY.placeholder" = "E-Mail-Adresse"; ================================================ FILE: SiskinIM/localization/de.lproj/Conversation.strings ================================================ /* Class = "UIButton"; normalTitle = "Accept"; ObjectID = "7TV-Kq-bdP"; */ "7TV-Kq-bdP.normalTitle" = "Akzeptieren"; /* Class = "UILabel"; text = "Label"; ObjectID = "V6M-WP-Vfp"; */ "V6M-WP-Vfp.text" = "Label"; ================================================ FILE: SiskinIM/localization/de.lproj/Groupchat.strings ================================================ /* Class = "UILabel"; text = "Notifications"; ObjectID = "02f-aw-3Zd"; */ "02f-aw-3Zd.text" = "Benachrichtigungen"; /* Class = "UITableViewSection"; footerTitle = "Push notification support depends on the XMPP server which you are using and may not work in some cases even if it's supported and enabled on the group chat"; ObjectID = "bRu-vo-fpu"; */ "bRu-vo-fpu.footerTitle" = "Push-Benachrichtigungen hängen vom verwendeten XMPP-Server ab und können in manchen Fällen nicht funktionieren, auch wenn sie im Gruppenchat unterstützt und aktiviert sind"; /* Class = "UITableViewSection"; headerTitle = "Settings"; ObjectID = "bRu-vo-fpu"; */ "bRu-vo-fpu.headerTitle" = "Einstellungen"; /* Class = "UILabel"; text = "Encryption"; ObjectID = "DQk-sn-hKj"; */ "DQk-sn-hKj.text" = "Verschlüsselung"; /* Class = "UILabel"; text = "When mentioned"; ObjectID = "exK-ZH-jpA"; */ "exK-ZH-jpA.text" = "Konto wählen"; /* Class = "UITableViewController"; title = "Room details"; ObjectID = "IGA-uE-mHb"; Note = "View with details of a room"; */ "IGA-uE-mHb.title" = "Gruppenchatdetails"; /* Class = "UITableViewSection"; headerTitle = "Subject"; ObjectID = "iYv-zL-tZT"; */ "iYv-zL-tZT.headerTitle" = "Thema"; /* Class = "UILabel"; text = "Push notifications"; ObjectID = "jMz-qR-fJ6"; */ "jMz-qR-fJ6.text" = "Push-Benachrichtigungen"; /* Class = "UITableViewController"; title = "Invite to chat"; ObjectID = "SOJ-3n-8YB"; */ "SOJ-3n-8YB.title" = "Zum Chat einladen"; /* Class = "UILabel"; text = "Attachments"; ObjectID = "wBw-1J-Xau"; */ "wBw-1J-Xau.text" = "Anhänge"; ================================================ FILE: SiskinIM/localization/de.lproj/Info.strings ================================================ /* Class = "UITableViewSection"; headerTitle = "Application"; ObjectID = "1Kk-Xc-9Ce"; */ "1Kk-Xc-9Ce.headerTitle" = "Anwendung"; /* Class = "UILabel"; text = "Website"; ObjectID = "4wJ-8E-L1J"; */ "4wJ-8E-L1J.text" = "Webseite"; /* Class = "UILabel"; text = "Website"; ObjectID = "56G-hc-AcV"; */ "56G-hc-AcV.text" = "Webseite"; /* Class = "UITableViewController"; title = "Get in touch"; ObjectID = "5mK-5u-qus"; */ "5mK-5u-qus.title" = "Kontakt aufnehmen"; /* Class = "UITableViewSection"; headerTitle = "Company"; ObjectID = "kdx-K5-gSV"; */ "kdx-K5-gSV.headerTitle" = "Firma"; /* Class = "UILabel"; text = "Version: 5.0"; ObjectID = "lRd-ky-eXs"; */ "lRd-ky-eXs.text" = "Version: 5.0"; /* Class = "UILabel"; text = "XMPP Channel"; ObjectID = "U4z-jz-alK"; */ "U4z-jz-alK.text" = "XMPP-Channel"; ================================================ FILE: SiskinIM/localization/de.lproj/LaunchScreen.strings ================================================ ================================================ FILE: SiskinIM/localization/de.lproj/Localizable.strings ================================================ /* section label, device memory */ "%@ memory" = "%@ Speicher"; /* no. of lines of messages preview label */ "%d lines of preview" = "%d Zeilen für die Vorschau"; /* conversation log groupchat direction label */ "(private message)" = "(Private Nachricht)"; /* conversation log label */ "(this message has been removed)" = "(diese Nachricht wurde entfernt)"; /* no. of lines of messages preview label */ "1 line of preview" = "1 Zeile für die Vorschau"; /* ssl certificate info - issue part */ "\nissued by\n%@\n with fingerprint\n%@" = "\nausgestellt von\n%1$@\n mit Fingerabdruck\n%2$@"; /* button label */ "Accept" = "Akzeptieren"; /* channel join status view label */ "Account" = "Konto"; /* alert title */ "Account removal" = "Konto entfernen"; /* section label */ "Accounts" = "Konten"; /* cell label */ "Add account" = "Konto hinzufügen"; /* action label */ "Add contact" = "Kontakt hinzufügen"; /* button label */ "Add existing" = "Vorhandenes hinzufügen"; /* contact details section vcard section label */ "Addresses" = "Adressen"; /* filter scope */ "All" = "Alle"; /* alert message - unblock communication with server */ "All communication with users from %@ is blocked. Do you wish to unblock communication with this server?" = "Jegliche Kommunikation mit Benutzern von %@ ist blockiert. Möchten Sie die Kommunikation mit diesem Server entsperren?"; /* alert body */ "All messages will be deleted and all participants will be kicked out. Are you sure?" = "Alle Nachrichten werden gelöscht und alle Teilnehmer werden rausgeworfen. Bist du dir sicher?"; /* conversation notifications status */ "Always" = "Immer"; /* attachemt label for conversations list */ "Attachment" = "Anhang"; /* section label */ "Attachments" = "Anhänge"; /* action label */ "Audio call" = "Audioanruf"; /* notification warning about authentication failure */ "Authentication for account %@ failed: %@" = "Authentifizierung für Konto %1$@ fehlgeschlagen: %2$@"; /* alert title body */ "Authentication for account %@ failed: %@\nVerify provided account password." = "Authentifizierung für Konto %1$@ fehlgeschlagen: %2$@\nÜberprüfe das eingegebene Kontopasswort."; /* alert title */ "Authentication issue" = "Authentifizierungsproblem"; /* appearance type */ "Auto" = "Automatisch"; /* audio output selection channel join status view label */ "Automatic" = "Automatisch"; /* filter scope */ "Available" = "Verfügbar"; /* presence status user status */ "Away" = "Weg"; /* action label */ "Ban user" = "Nutzer ausschließen"; /* alert title */ "Banning user" = "Nutzer ausschließen"; /* alert title */ "Banning user %@ failed" = "Nutzer %@ ausschließen fehlgeschlagen"; /* user status */ "Be right back" = "Bin gleich zurück"; /* alert body */ "Before changing roster you need to connect to server. Do you wish to do this now?" = "Bevor du die Kontaktliste ändern kannst, musst du dich mit dem Server verbinden. Möchtest du dies jetzt tun?"; /* vcard field label */ "Birthday" = "Geburtstag"; /* button label */ "Block" = "Sperren"; /* button label */ "Block and report" = "Sperren und melden"; /* context menu item */ "Block contact" = "Kontakt sperren"; /* action */ "Block participant" = "Teilnehmer sperren"; /* context menu item */ "Block server" = "Server sperren"; /* user status - contact blocked */ "Blocked" = "Verboten"; /* channel participants view operation */ "Blocking…" = "Sperren…"; /* search bar scope */ "By name" = "Nach Namen"; /* search bar scope */ "By status" = "Nach Status"; /* call state label */ "Call ended" = "Anruf beendet"; /* alert title */ "Call failed" = "Anruf fehlgeschlagen"; /* button label */ "Cancel" = "Abbrechen"; /* alert title */ "Certificate issue" = "Zertifikatsproblem"; /* button label */ "Change" = "Ändern"; /* button label */ "Change avatar" = "Profilbild ändern"; /* alert title button label */ "Change subject" = "Thema ändern"; /* alert title alert window title */ "Channel destruction failed!" = "Channel konnte nicht gelöscht werden!"; /* alert title */ "Channel destuction" = "Channel löschen"; /* action label presence status */ "Chat" = "Chat"; /* channel create view operation label channel join view operation label channel settings view opeartion label */ "Checking…" = "Überprüfen…"; /* button label */ "Close" = "Schließen"; /* channel join status view label */ "Component" = "Komponente"; /* call state label */ "Connecting…" = "Verbinden…"; /* error notification message */ "Connection to server %@ failed" = "Verbindung zum Server %@ fehlgeschlagen"; /* attachment cell context action context action context action label */ "Copy" = "Kopieren"; /* context action label */ "Correct…" = "Korrigieren…"; /* alert body */ "Could not create channel on the server. Got following error: %@" = "Konnte keinen Channel auf dem Server erstellen. Folgende Fehlermeldung erhalten: %@"; /* alert body */ "Could not delete account as it was not possible to connect to the XMPP server. Please try again later." = "Konnte das Konto nicht löschen, da keine Verbindung zum XMPP-Server hergestellt werden konnte. Bitte versuche es später noch einmal."; /* sharing error */ "Could not detect MIME type of a file." = "Konnte den MIME-Typ einer Datei nicht erkennen."; /* alert title */ "Could not join" = "Konnte nicht beitreten"; /* alert body */ "Could not join newly created channel '%@' on the server. Got following error: %@" = "Konnte dem neu erstellten Channel '%@' auf dem Server nicht beitreten. Folgende Fehlermeldung erhalten: %@"; /* alert body */ "Could not join room. Reason:\n%@" = "Konnte dem Raum nicht beitreten. Grund:\n%@"; /* alert body */ "Could not rename group chat. The server responded with an error: %@" = "Gruppenchat konnte nicht umbenannt werden. Der Server meldete den Fehler: %@"; /* sharing error */ "Could not retrieve file size." = "Konnte die Dateigröße nicht abrufen."; /* alert body */ "Could not set group chat avatar. The server responded with an error: %@" = "Gruppenchat-Profilbild konnte nicht festgelegt werden. Der Server meldete den Fehler: %@"; /* alert title */ "Could not update channel details" = "Konnte die Channeldetails nicht aktualisieren"; /* button label */ "Create" = "Erstellen"; /* button label */ "Create bookmark" = "Lesezeichen erstellen"; /* label for chats list new converation action */ "Create meeting" = "Konferenz erstellen"; /* button label */ "Create new" = "Neues erstellen"; /* cell sublabel */ "Create new or add existing account" = "Neues Konto erstellen oder vorhandenes Konto hinzufügen"; /* channel join view operation label */ "Creating channel…" = "Erstelle Channel…"; /* appearance type */ "Dark" = "Dunkel"; /* encryption default label encryption setting value */ "Default" = "Standard"; /* default roster group */ "Default " = "Standard "; /* alert body */ "Default account is not connected. Please select a different account." = "Standardkonto ist nicht verbunden. Bitte wähle ein anderes Konto."; /* action label attachment cell context action button label context action */ "Delete" = "Löschen"; /* alert title */ "Delete channel?" = "Channel löschen?"; /* button label */ "Destroy" = "Löschen"; /* alert title */ "Details" = "Details"; /* label for omemo device id */ "Device: %@" = "Gerät: %@"; /* button label */ "Disable autojoin" = "Auto-Join deaktivieren"; /* button label */ "Dismiss" = "Ablehnen"; /* section label */ "Display" = "Anzeigen"; /* label for chat marker */ "Displayed" = "Angezeigt"; /* presence status user status */ "Do not disturb" = "Nicht stören"; /* alert body */ "Do you want to ban user %@?" = "Möchtest du den Nutzer %@ ausschließen?"; /* alert body */ "Do you wish to publish this photo as avatar?" = "Möchtest du dieses Foto als Profilbild veröffentlichen?"; /* alert body */ "Do you wish to register a new account %@?" = "Möchtest du ein neues Konto registrieren %@?"; /* alert body */ "Do you wish to register a new account at %@?" = "Möchtest du ein neues Konto bei %@ registrieren?"; /* alert body */ "Do you wish to subscribe to \n%@\non account %@" = "Möchtest du Folgendes abonnieren \n%1$@\nmit dem Konto %2$@"; /* attachment cell context action confirmation dialog title */ "Download" = "Download"; /* alert title */ "Download storage" = "Downloadspeicher"; /* memory usage label */ "Downloads" = "Downloads"; /* action label */ "Edit" = "Bearbeiten"; /* contact details section vcard section label */ "Emails" = "E-Mails"; /* button label */ "Enable" = "Aktivieren"; /* button label */ "Enable autojoin" = "Auto-Join aktivieren"; /* option description */ "Enabling message synchronization will enable message archiving on the server" = "Durch die Aktivierung der Nachrichtensynchronisierung wird die Nachrichtenarchivierung auf dem Server aktiviert"; /* contact details section */ "Encryption" = "Verschlüsselung"; /* alert body */ "Enter default nickname to use in chats" = "Standard-Nickname für Chats eingeben"; /* alert body */ "Enter message to send to: %@" = "Nachricht für %@ eingeben:"; /* placeholder */ "Enter message…" = "Nachricht eingeben…"; /* alert body */ "Enter new name for group chat" = "Neuen Namen für Gruppenchat eingeben"; /* alert body */ "Enter new subject for group chat" = "Neues Thema für Gruppenchat eingeben"; /* alert body */ "Enter status message" = "Statusmeldung eingeben"; /* alert title */ "Error" = "Fehler"; /* error description message - detail */ "Error code" = "Fehlercode"; /* alert title */ "Error occurred" = "Fehler aufgetreten"; /* option to remove all data from local storage */ "Everything" = "Alles"; /* presence status */ "Extended away" = "Länger weg"; /* alert title alert window title */ "Failure" = "Fehlschlag"; /* vcard field label */ "Family name" = "Nachname"; /* sharing error */ "Feature not supported by XMPP server" = "Funktion wird vom XMPP-Server nicht unterstützt"; /* file size label */ "File - %@" = "Datei - %@"; /* confirmation dialog body */ "File is not available locally. Should it be downloaded?" = "Datei ist lokal nicht verfügbar. Soll sie heruntergeladen werden?"; /* sharing error */ "File is too big to share" = "Datei ist zum Teilen zu groß"; /* section label */ "Fingerprint of this device" = "Fingerabdruck dieses Geräts"; /* section label */ "For account" = "Für Konto"; /* memory usage label */ "Free" = "Frei"; /* user status */ "Free for chat" = "Zum Chatten verfügbar"; /* conversation log groupchat direction label */ "From" = "Von"; /* conversation view input field placeholder */ "from %@…" = "von %@…"; /* vcard field label */ "Full name" = "Vollständiger Name"; /* section label */ "General" = "Allgemein"; /* vcard field label */ "Given name" = "Vorname"; /* video quality */ "High" = "Hoch"; /* video quality */ "Highest" = "Höchste"; /* address type address type label */ "Home" = "Privat"; /* alert body push notifications option description */ "If enabled, you will receive notifications of new messages or calls even if SiskinIM is in background. SiskinIM servers will forward those notifications for you from XMPP servers." = "Wenn diese Option aktiviert ist, erhältst du Benachrichtigungen über neue Nachrichten oder Anrufe, auch wenn SiskinIM im Hintergrund läuft. Die SiskinIM-Server leiten diese Benachrichtigungen von XMPP-Servern für dich weiter."; /* section footer */ "If you don't know any XMPP server domain names, then select one of our trusted servers." = "Wenn du keine XMPP-Server-Domainnamen kennst, dann wähle einen unserer vertrauenswürdigen Server."; /* alert title */ "Incoming call" = "Eingehender Anruf"; /* alert body */ "Incoming call from %@" = "Eingehender Anruf von %@"; /* action label */ "Info" = "Info"; /* section label */ "Initial synchronization" = "Initiale Synchronisierung"; /* muc error reason */ "Invalid password" = "Ungültiges Passwort"; /* invitation label for chats list */ "Invitation" = "Einladung"; /* conversation log invitation to channel label */ "Invitation to channel %@" = "Einladung zum Channel %@"; /* muc invitation notification */ "Invitation to groupchat %@" = "Einladung zum Gruppenchat %@"; /* button label */ "Invite" = "Einladen"; /* button label */ "Invite…" = "Einladen…"; /* error message */ "It was not possible to access camera or microphone. Please check privacy settings" = "Der Zugriff auf Kamera und Mikrofon war nicht möglich. Bitte überprüfe die Datenschutzeinstellungen"; /* sharing error */ "It was not possible to access the file." = "Der Zugriff auf die Datei war nicht möglich."; /* push notifications registration failure message */ "It was not possible to contact push notification component." = "Die Kontaktaufnahme mit der Push-Benachrichtigungskomponente war nicht möglich."; /* push notifications registration failure message */ "It was not possible to contact push notification component.\nTry again later." = "Die Kontaktaufnahme mit der Push-Benachrichtigungskomponente war nicht möglich.\nVersuche es später noch einmal."; /* push notifications registration failure message */ "It was not possible to contact push notification component: %@" = "Die Kontaktaufnahme mit der Push-Benachrichtigungskomponente war nicht möglich: %@"; /* error message */ "It was not possible to contact XMPP server and sign in." = "XMPP-Server konnte nicht kontaktiert werden und die Anmeldung war nicht möglich."; /* alert body */ "It was not possible to create a meeting. Server returned an error: %@" = "Eine Konferenz konnte nicht erstellt werden. Der Server meldete den Fehler: %@"; /* alert body alert window message */ "It was not possible to destroy channel %@. Server returned an error: %@" = "Channel %1$@ konnte nicht gelöscht werden. Der Server meldete den Fehler: %2$@"; /* error message */ "It was not possible to establish call" = "Der Anruf konnte nicht hergestellt werden"; /* alert body */ "It was not possible to grant selected users access to the meeting. Received an error: %@" = "Den ausgewählten Teilnehmern konnte kein Zugriff auf die Konferenz gewährt werden. Ein Fehler ist aufgetreten: %@"; /* alert body */ "It was not possible to initiate a call: %@" = "Der Anruf konnte nicht hergestellt werden: %@"; /* alert button */ "It was not possible to join a channel. The server returned an error: %@" = "Der Beitritt zum Channel war nicht möglich. Der Server meldete einen Fehler: %@"; /* error description message */ "It was not possible to modify account." = "Das Konto konnte nicht geändert werden."; /* alert title */ "It was not possible to save account details" = "Kontodaten konnten nicht gespeichert werden"; /* alert body */ "It was not possible to save account details: %@" = "Kontodaten konnten nicht gespeichert werden: %@"; /* alert title body */ "It was not possible to save account details: %@ Please try again later." = "Kontodaten konnten nicht gespeichert werden: %@ Versuche es später noch einmal."; /* unsent messages notification */ "It was not possible to send %d messages. Open the app to retry" = "%d Nachrichten konnten nicht gesendet werden. Öffne die App, um es erneut zu versuchen"; /* message encryption failure */ "It was not possible to send encrypted message due to encryption error" = "Verschlüsselte Nachricht konnte aufgrund eines Verschlüsselungsfehlers nicht gesendet werden"; /* button label */ "Join" = "Beitreten"; /* label for chats list new converation action */ "Join group chat" = "Gruppenchat beitreten"; /* action label */ "Join room" = "Raum beitreten"; /* channel status label */ "Joined" = "Beigetreten"; /* channel join view operation label muc room status */ "Joining…" = "Beitreten…"; /* report spam action */ "Just block" = "Nur sperren"; /* no OMEMO key - not generated yet */ "Key not generated!" = "Schlüssel nicht generiert!"; /* button label */ "Kick out" = "Rauswerfen"; /* option description */ "Large value may increase inital synchronization time" = "Hoher Wert kann die initiale Synchronisationszeit erhöhen"; /* button label */ "Leave" = "Verlassen"; /* leaving channel title */ "Leaving channel" = "Channel verlassen"; /* appearance type */ "Light" = "Hell"; /* option description */ "Limits the size of the files sent to you which may be automatically downloaded" = "Begrenzt die Größe der automatisch heruntergeladenen Dateien, die an dich gesendet werden"; /* memory usage label */ "Link previews" = "Linkvorschau"; /* section label */ "List of messages" = "Liste der Nachrichten"; /* attachemt label for conversations list */ "Location" = "Standort"; /* error message */ "Login and password do not match." = "Login und Passwort stimmen nicht überein."; /* video quality */ "Low" = "Niedrig"; /* muc error reason */ "Maximum number of users exceeded" = "Maximale Teilnehmerzahl überschritten"; /* me label for conversation log */ "Me" = "Ich"; /* video quality */ "Medium" = "Mittel"; /* alert title */ "Meeting ended" = "Konferenz beendet"; /* alert body */ "Meeting has ended" = "Konferenz wurde beendet"; /* muc error reason */ "Membership is required to access the room" = "Für den Zugang ist eine Mitgliedschaft erforderlich"; /* message decryption error message encryption failure */ "Message decryption failed! Error code: %d" = "Entschlüsselung der Nachricht fehlgeschlagen! Fehlercode: %d"; /* alert body */ "Message moderation failed!" = "Nachrichtenmoderation fehlgeschlagen!"; /* section label */ "Message synchronization" = "Nachrichtensynchronisierung"; /* message encryption failure */ "Message was not encrypted for this device" = "Nachricht wurde nicht für dieses Gerät verschlüsselt"; /* message decryption error */ "Message was not encrypted for this device." = "Nachricht wurde nicht für dieses Gerät verschlüsselt."; /* section label */ "Messages" = "Nachrichten"; /* alert title */ "Metadata storage" = "Metadaten-Speicher"; /* alert title */ "Missed call" = "Verpasster Anruf"; /* alert body */ "Missed incoming call from %@" = "Verpasster Anruf von %@"; /* context action label */ "Moderate" = "Moderater"; /* list of users with this role */ "Moderators" = "Moderatoren"; /* synchronization period value */ "Month" = "Monat"; /* attachment cell context action context action label */ "More…" = "Mehr…"; /* conversation notifications status */ "Muted" = "Stumm"; /* call state label */ "New call" = "Neuer Aufruf"; /* notification of incoming message on locked screen */ "New message" = "Neue Nachricht"; /* new message without content notification */ "New message!" = "Neue Nachricht!"; /* label for chats list new converation action */ "New private group chat" = "Neuer privater Gruppenchat"; /* label for chats list new converation action */ "New public group chat" = "Neuer öffentlicher Channel"; /* alert title */ "Nickname" = "Nickname"; /* muc error reason */ "Nickname already in use" = "Nickname wird bereits verwendet"; /* muc error reason */ "Nickname is locked down" = "Nickname ist gesperrt"; /* button label */ "No" = "Nein"; /* attachments view label */ "No attachments" = "Keine Anhänge"; /* channel join status view label encryption type encyption option list of users with this role */ "None" = "Keine"; /* channel status label muc room status label */ "Not connected" = "Nicht verbunden"; /* channel status label */ "Not joined" = "Nicht beigetreten"; /* action label */ "Nothing" = "Nichts"; /* muc room status user status */ "Offline" = "Offline"; /* action label Button button label button lable */ "OK" = "OK"; /* option to remove all data from local storage option to remove data older than 7 days */ "Older than 7 days" = "Älter als 7 Tage"; /* encryption option encryption type */ "OMEMO" = "OMEMO"; /* muc room status presence status user status */ "Online" = "Online"; /* action label */ "Open chat" = "Chat öffnen"; /* alert title */ "Open URL" = "URL öffnen"; /* vcard field label */ "Organization" = "Organisation"; /* vcard field label */ "Organization role" = "Funktion in der Organisation"; /* video quality */ "Original" = "Original"; /* selection warning */ "Original quality will share image in the format in which it is stored on your phone and it may not be supported by every device." = "Die Originalqualität gibt das Bild in dem Format weiter, in dem es auf deinem Telefon gespeichert ist, und es wird möglicherweise nicht von jedem Gerät unterstützt."; /* selection warning */ "Original quality will share video in the format in which video is stored on your phone and it may not be supported by every device." = "Die Originalqualität gibt das Video in dem Format weiter, in dem es auf deinem Telefon gespeichert ist, und es wird möglicherweise nicht von jedem Gerät unterstützt."; /* memory usage label */ "Other apps" = "Andere Apps"; /* section label */ "Other devices fingerprints" = "Fingerabdrücke anderer Geräte"; /* list of users with this role */ "Participants" = "Teilnehmer"; /* button label */ "Pass ownership" = "Eigentümerstatus übertragen"; /* contact details section vcard section label */ "Phones" = "Telefone"; /* voice message state */ "Playing…" = "Wiedergeben…"; /* instruction to fill out the form */ "Please fill this form" = "Bitte dieses Formular ausfüllen"; /* alert title */ "Please launch application from the home screen before continuing." = "Bitte starte die App auf dem Startbildschirm, bevor du fortfährst."; /* sharing error */ "Please try again later." = "Bitte später erneut versuchen."; /* section label */ "Preferred domain name" = "Bevorzugter Domainname"; /* operation label */ "Preparing…" = "Vorbereiten…"; /* attachment cell context action context action */ "Preview" = "Vorschau"; /* action label */ "Private message" = "Private Nachricht"; /* account registration error */ "Provided values are not acceptable" = "Eingegebene Werte sind nicht zulässig"; /* alert title */ "Push notifications" = "Push-Benachrichtigungen"; /* alert title */ "Push Notifications" = "Push-Benachrichtigungen"; /* alert body */ "Push notifications are enabled for %@. They need to be disabled before account can be removed and it is not possible to at this time. Please try again later." = "Push-Benachrichtigungen sind für %@ aktiviert. Diese müssen deaktiviert werden, bevor das Konto entfernt werden kann, und das ist derzeit nicht möglich. Bitte versuche es später noch einmal."; /* push notifications registration failure message */ "Push notifications not available" = "Push-Benachrichtigungen nicht verfügbar"; /* section label */ "Quality of uploaded media" = "Qualität der hochgeladenen Medien"; /* synchronization period value */ "Quarter" = "Quartal"; /* alert title */ "Question" = "Frage"; /* label for chat marker */ "Received" = "Empfangen"; /* presence subscription request notification */ "Received presence subscription request from %@" = "Anfrage für Online-Status von %@ erhalten"; /* alert title body */ "Received presence subscription request from\n%@\non account %@" = "Anfrage für Online-Status von\n%1$@\nfür das Konto %2$@ erhalten"; /* voice message state */ "Recorded: %@" = "Aufgenommen: %@"; /* voice message state */ "Recording…" = "Aufnehmen…"; /* voice message state */ "Recording… %@" = "Aufnehmen… %@"; /* channel block users view operation channel edit info operation channel participants view operation */ "Refreshing…" = "Aktualisieren…"; /* button label */ "Register" = "Registrieren"; /* alert title */ "Registering account" = "Konto registrieren"; /* alert title */ "Registration failure" = "Registrierung fehlgeschlagen"; /* account registration error */ "Registration is not supported by this server" = "Registrierung wird von diesem Server nicht unterstützt"; /* button label */ "Reject" = "Ablehnen"; /* alert body */ "Remote server returned an error: %@" = "Remote-Server hat einen Fehler gemeldet: %@"; /* option description */ "Removal of cached attachments may lead to increased usage of network, if attachment may need to be redownloaded, or to lost files, if they are no longer available at the server." = "Das Entfernen von zwischengespeicherten Anhängen kann zu einer erhöhten Belastung des Netzes führen, wenn Anhänge erneut heruntergeladen werden müssen, oder zu verlorenen Dateien, wenn sie nicht mehr auf dem Server verfügbar sind."; /* alert body */ "Remove account from application?" = "Konto aus der App entfernen?"; /* button label */ "Remove bookmark" = "Lesezeichen entfernen"; /* button label */ "Remove from application" = "Aus der App entfernen"; /* button label */ "Remove from server" = "Vom Server entfernen"; /* button label */ "Rename" = "Umbenennen"; /* alert title button label */ "Rename chat" = "Chat umbenennen"; /* context action label */ "Reply…" = "Antworten…"; /* context action label */ "Report & block…" = "Melden & sperren…"; /* report abuse action */ "Report abuse" = "Missbrauch melden"; /* report spam action */ "Report spam" = "Spam melden"; /* button label */ "Resend" = "Erneut senden"; /* context action label */ "Retract" = "Zurückziehen"; /* call state label */ "Ringing…" = "Anrufen…"; /* alert title */ "Room %@" = "Raum %@"; /* muc error reason */ "Room is locked" = "Raum ist gesperrt"; /* alert body */ "Room was created and joined but room was not properly configured. Got following error: %@" = "Raum wurde erstellt und beigetreten, aber der Raum wurde nicht richtig konfiguriert. Folgender Fehler wurde gemeldet: %@"; /* search bar placeholder */ "Search channels" = "Channels suchen"; /* placeholder for location selection search bar */ "Search for places" = "Suche nach Orten"; /* placeholder */ "Search to add…" = "Suche hinzufügen…"; /* alert body */ "Select account to open chat from" = "Konto für den Chat auswählen"; /* selection information */ "Select appearance" = "Aussehen auswählen"; /* selection application icon information */ "Select application icon" = "Anwendungssymbol auswählen"; /* title for multiple contact selection */ "Select contacts" = "Kontakte auswählen"; /* selection information */ "Select default conversation encryption" = "Standardmäßige Nachrichtenverschlüsselung auswählen"; /* location selection window title */ "Select location" = "Standort auswählen"; /* selection description */ "Select period of messages to be synchronized" = "Zeitraum der zu synchronisierenden Nachrichten auswählen"; /* button label photo selection action */ "Select photo" = "Foto auswählen"; /* media quality selection instruction */ "Select quality" = "Qualität auswählen"; /* selection description */ "Select quality of the image to use for sharing" = "Bildqualität zum Teilen auswählen"; /* selection description */ "Select quality of the video to use for sharing" = "Videoqualität zum Teilen auswählen"; /* view title */ "Select recipients" = "Empfänger auswählen"; /* alert title */ "Select status" = "Status festlegen"; /* button label */ "Send" = "Senden"; /* alert title */ "Send message" = "Nachricht senden"; /* channel invitations view operation */ "Sending invitations…" = "Einladungen senden…"; /* operation label */ "Sending…" = "Senden…"; /* sharing error */ "Server did not confirm file upload correctly." = "Server hat den Datei-Upload nicht korrekt bestätigt."; /* ssl certificate alert dialog body */ "Server for domain %@ provided invalid certificate for %@\n with fingerprint\n%@%@.\nDo you trust this certificate?" = "Server für Domain %1$@ hat ungültiges Zertifikat für %2$@\n mit Fingerabdruck\n%3$@%4$@.\nVertraust du diesem Zertifikat?"; /* alert title - unblock communication with server */ "Server is blocked" = "Server ist gesperrt"; /* alert body */ "Server of selected account does not provide support for hosting meetings. Please select a different account." = "Der Server des ausgewählten Kontos bietet keine Unterstützung für Konferenzen. Bitte wähle ein anderes Konto."; /* account registration error alert body */ "Server returned an error: %@" = "Server meldet einen Fehler: %@"; /* account registration error */ "Service is not available at this time." = "Service ist zur Zeit nicht verfügbar."; /* alert title */ "Service unavailable" = "Service nicht verfügbar"; /* button label */ "Set" = "Einstellen"; /* contact details section section label */ "Settings" = "Einstellungen"; /* attachment cell context action context action context action label */ "Share…" = "Teilen…"; /* section label */ "Sharing" = "Teilen"; /* alert body */ "Sharing feature with HTTP upload is disabled within application. To use this feature you need to enable sharing with HTTP upload in application" = "Die Funktion zum Teilen mit HTTP-Upload ist in der App deaktiviert. Um diese Funktion zu nutzen, musst du die Freigabe mit HTTP-Upload in der App aktivieren"; /* alert body */ "Should account be removed from server as well?" = "Soll das Konto auch auf dem Server gelöscht werden?"; /* context action label */ "Show map" = "Karte anzeigen"; /* notifications from unknown description */ "Show notifications from people not in your contact list" = "Benachrichtigungen von Personen anzeigen, die nicht in deiner Kontaktliste sind"; /* App icon */ "Simple" = "Einfach"; /* audio output label */ "Speaker" = "Lautsprecher"; /* alert title */ "Start chatting" = "Chat starten"; /* alert title section label */ "Status" = "Status"; /* alert title */ "Subscribe to %@" = "%@ abonnieren"; /* alert title */ "Subscription request" = "Anfrage für Online-Status"; /* button label */ "Switch audio" = "Audio wechseln"; /* button label */ "Switch camera" = "Kamera wechseln"; /* button label photo selection action */ "Take photo" = "Foto aufnehmen"; /* report user message */ "The user %@ will be blocked. Should it be reported as well?" = "Der Nutzer %@ wird gesperrt. Soll er auch gemeldet werden?"; /* alert message */ "There is no service supporting channels for domain %@" = "Kein Service unterstützt Channels für die Domain %@"; /* message encryption failure */ "There is no trusted device to send message to" = "Es gibt kein vertrauenswürdiges Gerät, an das eine Nachricht gesendet werden kann"; /* alert body */ "This room is not capable of sending encrypted messages. Please change encryption settings to be able to send messages" = "Dieser Gruppenchat ist nicht in der Lage, verschlüsselte Nachrichten zu versenden. Bitte ändere die Verschlüsselungseinstellungen, um Nachrichten senden zu können"; /* conversation log groupchat direction label */ "To" = "An"; /* section label */ "Trusted servers" = "Vertrauenswürdige Server"; /* error recovery suggestion */ "Try again. If removal failed, try accessing Keychain to update account credentials manually." = "Versuche es erneut. Wenn die Entfernung fehlgeschlagen ist, dann versuche, auf den Schlüsselbund zuzugreifen, um die Kontozugangsdaten manuell zu aktualisieren."; /* synchronization period value */ "Two weeks" = "Zwei Wochen"; /* action button label */ "Unblock" = "Entsperren"; /* context menu action */ "Unblock server" = "Server entsperren"; /* alert body */ "Unknown error occurred" = "Unbekannter Fehler aufgetreten"; /* unknown file label */ "Unknown file" = "Unbekannte Datei"; /* allowed size of file to download */ "Unlimited" = "Unbegrenzt"; /* conversation log label */ "Unread messages" = "Ungelesene Nachrichten"; /* channel block users view operation channel edit info operation refresh conrol label */ "Updating…" = "Aktualisieren…"; /* alert title */ "Upload failed" = "Upload fehlgeschlagen"; /* sharing error */ "Upload to HTTP server failed." = "Upload auf HTTP-Server fehlgeschlagen."; /* option description */ "Used image and video quality may impact storage and network usage" = "Die verwendete Bild- und Videoqualität kann sich auf die Speicher- und Netzwerknutzung auswirken"; /* alert body */ "User avatar publication failed.\nReason: %@" = "Veröffentlichung des Profilbilds fehlgeschlagen.\nGrund: %@"; /* muc error reason */ "User is banned" = "Nutzer ist ausgeschlossen"; /* account registration error */ "User with provided username already exists" = "Der angegebene Benutzername existiert bereits"; /* account info label */ "using %@" = "verwende %@"; /* alert body */ "VCard publication failed: %@" = "vCard-Veröffentlichung fehlgeschlagen: %@"; /* version of the app */ "Version: %@" = "Version: %@"; /* action label */ "Video call" = "Videoanruf"; /* list of users with this role */ "Visitors" = "Besucher"; /* alert title */ "Warning" = "Warnung"; /* alert body used space label */ "We are using %@ of storage." = "Wir nutzen %@ des Speichers."; /* synchronization period value */ "Week" = "Woche"; /* alert body */ "What do you want to do with %@?" = "Was möchtest du mit %@ machen?"; /* conversation notifications status */ "When mentioned" = "Wenn erwähnt"; /* alert body */ "When you share files using HTTP, they are uploaded to HTTP server with unique URL. Anyone who knows the unique URL to the file is able to download it.\nDo you wish to enable?" = "Wenn du Dateien über HTTP teilst, werden sie mit einer eindeutigen URL auf den HTTP-Server hochgeladen. Jeder, der die eindeutige URL der Datei kennt, kann sie herunterladen.\nMöchtest du diese Funktion aktivieren?"; /* alert body */ "When you share files, they are uploaded to HTTP server with unique URL. Anyone who knows the unique URL to the file is able to download it.\nDo you wish to proceed?" = "Wenn du Dateien teilst, werden sie mit einer eindeutigen URL auf den HTTP-Server hochgeladen. Jeder, der die eindeutige URL der Datei kennt, kann sie herunterladen.\nMöchtest du fortfahren?"; /* address type address type label */ "Work" = "Arbeit"; /* synchronization period value */ "Year" = "Jahr"; /* button label */ "Yes" = "Ja"; /* alert body */ "You are about to destroy channel %@. This will remove the channel on the server, remove remote history archive, and kick out all participants. Are you sure?" = "Du bist im Begriff, den Channel %@ zu löschen. Dadurch wird der Channel auf dem Server gelöscht, das Verlaufsarchiv gelöscht und alle Teilnehmer rausgeworfen. Bist du dir sicher?"; /* error label */ "You are invied to join conversation at %@" = "Du bist zur Teilnahme an der Unterhaltung unter %@ eingeladen"; /* alert body */ "You are not connected to room.\nPlease wait reconnection to room" = "Du bist nicht mit dem Gruppenchat verbunden.\nBitte warte auf die erneute Verbindung zum Gruppenchat"; /* alert body */ "You are not joined to the channel." = "Du bist dem Channel nicht beigetreten."; /* leaving channel text */ "You are the last person with ownership of this channel. Please decide what to do with the channel." = "Du bist die letzte Person, die Eigentümer dieses Channels ist. Bitte entscheide, was du mit dem Channel machen willst."; /* push notifications not allowed warning */ "You need to allow application to show notifications and for background refresh." = "Du musst der App erlauben, Benachrichtigungen anzuzeigen und im Hintergrund zu aktualisieren."; /* alert body */ "You've left there room %@ and push notifications for this room were disabled!\nYou may need to reenable them on other devices." = "Du hast den Raum %@ verlassen und Push-Benachrichtigungen für diesen Raum wurden deaktiviert!\nMöglicherweise musst du sie auf anderen Geräten wieder aktivieren."; /* search location pin label */ "Your location" = "Dein Standort"; ================================================ FILE: SiskinIM/localization/de.lproj/MIX.strings ================================================ /* Class = "UITextField"; placeholder = "Automatic"; ObjectID = "19A-3H-7QN"; */ "19A-3H-7QN.placeholder" = "Automatisch"; /* Class = "UILabel"; text = "Use MIX"; ObjectID = "4P1-zT-Par"; */ "4P1-zT-Par.text" = "MIX nutzen"; /* Class = "UITableViewSection"; footerTitle = "Enter domain name of a component with channel or leave blank to automatically detect components with channels"; ObjectID = "94L-uC-ldZ"; */ "94L-uC-ldZ.footerTitle" = "Domainnamen einer Komponente mit Channel eingeben oder leer lassen, um Komponenten mit Channels automatisch zu erkennen"; /* Class = "UITableViewSection"; headerTitle = "Component domain"; ObjectID = "94L-uC-ldZ"; */ "94L-uC-ldZ.headerTitle" = "Domain der Komponente"; /* Class = "UILabel"; text = "Create"; ObjectID = "9Ww-8v-Dml"; */ "9Ww-8v-Dml.text" = "Erstellen"; /* Class = "UITableViewSection"; headerTitle = "Experimental"; ObjectID = "aqR-Sm-2re"; */ "aqR-Sm-2re.headerTitle" = "Experimentell"; /* Class = "UINavigationItem"; title = "Select channel"; ObjectID = "C5U-9C-poe"; */ "C5U-9C-poe.title" = "Channel auswählen"; /* Class = "UITableViewSection"; headerTitle = "Bookmark"; ObjectID = "cOt-Oi-tab"; */ "cOt-Oi-tab.headerTitle" = "Lesezeichen"; /* Class = "UIBarButtonItem"; title = "Item"; ObjectID = "cv8-VS-8c6"; */ "cv8-VS-8c6.title" = "Eintrag"; /* Class = "UILabel"; text = "Autojoin"; ObjectID = "da6-M6-5nm"; */ "da6-M6-5nm.text" = "Auto-Join"; /* Class = "UILabel"; text = "Notifications"; ObjectID = "fFa-pN-ndo"; */ "fFa-pN-ndo.text" = "Benachrichtigungen"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "gNa-Cz-T88"; Note = "Placeholder for a field to notify user that it needs to be filled"; */ "gNa-Cz-T88.placeholder" = "erforderlich"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "Jiu-Fd-AsM"; */ "Jiu-Fd-AsM.placeholder" = "erforderlich"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "kD7-lz-IEK"; Note = "Placeholder for a field to notify user that it needs to be filled"; */ "kD7-lz-IEK.placeholder" = "erforderlich"; /* Class = "UITableViewController"; title = "Channel details"; ObjectID = "ke4-WK-unt"; */ "ke4-WK-unt.title" = "Channeldetails"; /* Class = "UIBarButtonItem"; title = "Invite"; ObjectID = "Kre-sS-2vH"; */ "Kre-sS-2vH.title" = "Einladen"; /* Class = "UITextField"; placeholder = "Description"; ObjectID = "MsF-6z-TY3"; */ "MsF-6z-TY3.placeholder" = "Beschreibung"; /* Class = "UINavigationItem"; title = "Participants"; ObjectID = "mW8-st-X8N"; */ "mW8-st-X8N.title" = "Teilnehmer"; /* Class = "UITableViewSection"; headerTitle = "Access"; ObjectID = "mXt-Xt-Bj5"; */ "mXt-Xt-Bj5.headerTitle" = "Zugang"; /* Class = "UILabel"; text = "Attachments"; ObjectID = "NWM-d4-jmq"; */ "NWM-d4-jmq.text" = "Anhänge"; /* Class = "UITableViewSection"; headerTitle = "Nickname"; ObjectID = "NyO-PD-t9d"; */ "NyO-PD-t9d.headerTitle" = "Nickname"; /* Class = "UILabel"; text = "Delete channel"; ObjectID = "O9e-sP-5IO"; */ "O9e-sP-5IO.text" = "Channel löschen"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "oIZ-xz-SzZ"; */ "oIZ-xz-SzZ.placeholder" = "erforderlich"; /* Class = "UIBarButtonItem"; title = "Join"; ObjectID = "Pgv-Uz-ZgP"; */ "Pgv-Uz-ZgP.title" = "Beitreten"; /* Class = "UIBarButtonItem"; title = "Next"; ObjectID = "pZC-YZ-jlg"; */ "pZC-YZ-jlg.title" = "Weiter"; /* Class = "UITableViewSection"; headerTitle = "Password"; ObjectID = "qB9-Eq-3RT"; */ "qB9-Eq-3RT.headerTitle" = "Passwort"; /* Class = "UITextField"; placeholder = "Name"; ObjectID = "r60-FV-hoE"; */ "r60-FV-hoE.placeholder" = "Name"; /* Class = "UILabel"; text = "Change"; ObjectID = "Sso-1F-PI3"; */ "Sso-1F-PI3.text" = "Ändern"; /* Class = "UITableViewSection"; footerTitle = "Select which account should be used to join channel"; ObjectID = "SUF-pg-qbn"; */ "SUF-pg-qbn.footerTitle" = "Auswählen, welches Konto für das Beitreten zum Channel verwendet werden soll"; /* Class = "UITableViewSection"; headerTitle = "Account"; ObjectID = "SUF-pg-qbn"; */ "SUF-pg-qbn.headerTitle" = "Konto"; /* Class = "UITableViewSection"; footerTitle = "Notification support and filtering depends on the XMPP server which you are using and may not work in some cases even if it's enabled here."; ObjectID = "Ta8-dG-cwn"; */ "Ta8-dG-cwn.footerTitle" = "Unterstützung und Filterung von Benachrichtigungen hängen von dem XMPP-Server ab, den du verwendest, und können in manchen Fällen nicht funktionieren, selbst wenn sie hier aktiviert sind."; /* Class = "UITableViewSection"; headerTitle = "Settings"; ObjectID = "Ta8-dG-cwn"; */ "Ta8-dG-cwn.headerTitle" = "Einstellungen"; /* Class = "UITableViewSection"; footerTitle = "ID of a channel used for joining (localpart of a JID)"; ObjectID = "u6K-qu-Pf5"; */ "u6K-qu-Pf5.footerTitle" = "ID des für den Beitritt verwendeten Channels (lokaler Teil eines JID)"; /* Class = "UITableViewSection"; headerTitle = "Channel ID"; ObjectID = "u6K-qu-Pf5"; */ "u6K-qu-Pf5.headerTitle" = "Channel-ID"; /* Class = "UINavigationItem"; title = "Blocked"; ObjectID = "uMJ-O9-BGV"; */ "uMJ-O9-BGV.title" = "Gesperrt"; /* Class = "UIBarButtonItem"; title = "Next"; ObjectID = "uX9-lh-BwU"; */ "uX9-lh-BwU.title" = "Weiter"; /* Class = "UITableViewSection"; headerTitle = "Channel name"; ObjectID = "vcg-og-awz"; */ "vcg-og-awz.headerTitle" = "Channelname"; /* Class = "UIBarButtonItem"; title = "Item"; ObjectID = "VkQ-fL-ON5"; */ "VkQ-fL-ON5.title" = "Eintrag"; /* Class = "UINavigationItem"; title = "Create channel"; ObjectID = "Vna-Fa-6lB"; */ "Vna-Fa-6lB.title" = "Channel erstellen"; /* Class = "UILabel"; text = "Invitation only"; ObjectID = "Xcj-Am-jtk"; */ "Xcj-Am-jtk.text" = "Nur mit Einladung"; ================================================ FILE: SiskinIM/localization/de.lproj/Main.strings ================================================ /* Class = "UILabel"; text = "by Tigase, Inc."; ObjectID = "8ba-yZ-XRA"; */ "8ba-yZ-XRA.text" = "von Tigase, Inc."; /* Class = "UITabBarItem"; title = "Chats"; ObjectID = "acW-dT-cKf"; */ "acW-dT-cKf.title" = "Chats"; /* Class = "UITextField"; placeholder = "Enter jid"; ObjectID = "BM3-28-huR"; */ "BM3-28-huR.placeholder" = "XMPP-Adresse eingeben"; /* Class = "UINavigationItem"; title = "Informations"; ObjectID = "bNp-S8-ulX"; */ "bNp-S8-ulX.title" = "Informationen"; /* Class = "UINavigationItem"; title = "Attachments"; ObjectID = "C1j-4i-HDP"; */ "C1j-4i-HDP.title" = "Anhänge"; /* Class = "UITabBarItem"; title = "Bookmarks"; ObjectID = "dgT-Yo-7nF"; */ "dgT-Yo-7nF.title" = "Lesezeichen"; /* Class = "UILabel"; text = "Attachments"; ObjectID = "EpU-tc-DIx"; */ "EpU-tc-DIx.text" = "Anhänge"; /* Class = "UILabel"; text = "Ask for presence updates"; ObjectID = "ffo-7n-Tn2"; */ "ffo-7n-Tn2.text" = "Online-Status anfragen"; /* Class = "UILabel"; text = "Mute contact"; ObjectID = "g8N-kr-fax"; */ "g8N-kr-fax.text" = "Kontakt stummschalten"; /* Class = "UITextField"; placeholder = "Select account"; ObjectID = "gXY-yq-Y2K"; */ "gXY-yq-Y2K.placeholder" = "Konto wählen"; /* Class = "UITextField"; text = "local_user@example.com"; ObjectID = "gXY-yq-Y2K"; */ "gXY-yq-Y2K.text" = "nutzer@beispiel.de"; /* Class = "UITableViewSection"; headerTitle = "Name"; ObjectID = "Kfl-J5-hdD"; */ "Kfl-J5-hdD.headerTitle" = "Name"; /* Class = "UILabel"; text = "Block contact"; ObjectID = "kwQ-p7-cX2"; */ "kwQ-p7-cX2.text" = "Kontakt sperren"; /* Class = "UITableViewSection"; headerTitle = "Account"; ObjectID = "Mfv-HJ-QDO"; */ "Mfv-HJ-QDO.headerTitle" = "Konto"; /* Class = "UICollectionViewController"; title = "Attachments"; ObjectID = "N9z-ms-iaT"; */ "N9z-ms-iaT.title" = "Anhänge"; /* Class = "UINavigationController"; title = "Contacts"; ObjectID = "Ndx-if-NHK"; */ "Ndx-if-NHK.title" = "Kontakte"; /* Class = "UITextField"; placeholder = "Enter display name"; ObjectID = "OGf-mX-8z3"; */ "OGf-mX-8z3.placeholder" = "Namen eingeben, der angezeigt werden soll"; /* Class = "UITableViewSection"; headerTitle = "PRESENCE"; ObjectID = "Q28-Ig-9NP"; */ "Q28-Ig-9NP.headerTitle" = "ONLINE-STATUS"; /* Class = "UITableViewSection"; headerTitle = "XMPP Address (JID)"; ObjectID = "Qgj-eQ-geg"; */ "Qgj-eQ-geg.headerTitle" = "XMPP-Adresse (JID)"; /* Class = "UITableViewController"; title = "Bookmarks"; ObjectID = "rba-pb-IiC"; */ "rba-pb-IiC.title" = "Lesezeichen"; /* Class = "UILabel"; text = "Disclose my online status"; ObjectID = "upM-mW-rMZ"; */ "upM-mW-rMZ.text" = "Meinen Online-Status veröffentlichen"; /* Class = "UITabBarItem"; title = "Contacts"; ObjectID = "W52-LN-wzX"; */ "W52-LN-wzX.title" = "Kontakte"; /* Class = "UIButton"; normalTitle = "Create new XMPP account"; ObjectID = "WyQ-cb-VAl"; */ "WyQ-cb-VAl.normalTitle" = "Neues XMPP-Konto erstellen"; /* Class = "UILabel"; text = "Message encryption"; ObjectID = "XZp-oZ-XpC"; */ "XZp-oZ-XpC.text" = "Nachrichtenverschlüsselung"; /* Class = "UIButton"; normalTitle = "Sign in to an existing XMPP account"; ObjectID = "ZzM-t7-zvW"; */ "ZzM-t7-zvW.normalTitle" = "Mit einem vorhandenen XMPP-Konto anmelden"; ================================================ FILE: SiskinIM/localization/de.lproj/Settings.strings ================================================ /* Class = "UINavigationController"; title = "Settings"; ObjectID = "15C-WY-qrp"; */ "15C-WY-qrp.title" = "Einstellungen"; /* Class = "UILabel"; text = "Chat markers & receipts"; ObjectID = "32B-bc-CHX"; */ "32B-bc-CHX.text" = "Chatmarkierungen & Empfangsbestätigungen"; /* Class = "UILabel"; text = "Show emoticons"; ObjectID = "3a8-1d-3PR"; */ "3a8-1d-3PR.text" = "Emoticons anzeigen"; /* Class = "UILabel"; text = "Notifications"; ObjectID = "3pd-ay-sAI"; */ "3pd-ay-sAI.text" = "Benachrichtigungen"; /* Class = "UILabel"; text = "Notifications from unknown"; ObjectID = "67H-51-GcY"; */ "67H-51-GcY.text" = "Benachrichtigungen von Unbekannten"; /* Class = "UILabel"; text = "Media"; ObjectID = "8G3-tm-sCO"; */ "8G3-tm-sCO.text" = "Medien"; /* Class = "UILabel"; text = "Enable markdown"; ObjectID = "8K5-bS-ddh"; */ "8K5-bS-ddh.text" = "Markdown aktivieren"; /* Class = "UILabel"; text = "File download limit"; ObjectID = "8WB-sA-XVp"; */ "8WB-sA-XVp.text" = "Limit für Dateidownloads"; /* Class = "UILabel"; text = "Get in touch"; ObjectID = "91g-qn-FOS"; */ "91g-qn-FOS.text" = "Kontakt aufnehmen"; /* Class = "UILabel"; text = "About the app"; ObjectID = "9cK-3e-Nqx"; */ "9cK-3e-Nqx.text" = "Über die App"; /* Class = "UILabel"; text = "No blocked contacts"; ObjectID = "Aki-gv-qus"; */ "Aki-gv-qus.text" = "Keine gesperrten Kontakte"; /* Class = "UILabel"; text = "Photos quality"; ObjectID = "bbT-7s-6ph"; */ "bbT-7s-6ph.text" = "Fotoqualität"; /* Class = "UILabel"; text = "Send message on Return"; ObjectID = "Bd4-qf-OsU"; */ "Bd4-qf-OsU.text" = "Nachricht bei Rückkehr senden"; /* Class = "UILabel"; text = "Videos quality"; ObjectID = "bKW-ln-Kez"; */ "bKW-ln-Kez.text" = "Videoqualität"; /* Class = "UILabel"; text = "Use public STUN servers"; ObjectID = "BSa-RZ-M7L"; */ "BSa-RZ-M7L.text" = "Öffentliche STUN-Server verwenden"; /* Class = "UILabel"; text = "Contacts in groups"; ObjectID = "BSk-tI-BDM"; */ "BSk-tI-BDM.text" = "Kontakte in Gruppen"; /* Class = "UILabel"; text = "Push notifications"; ObjectID = "cyb-st-Bmw"; */ "cyb-st-Bmw.text" = "Push-Benachrichtigungen"; /* Class = "UILabel"; text = "XMPP Quickstart / Pipelining"; ObjectID = "dbq-Tl-d6b"; */ "dbq-Tl-d6b.text" = "XMPP-Schnellstart / Pipelining"; /* Class = "UILabel"; text = "Chats"; ObjectID = "etv-3w-9lA"; */ "etv-3w-9lA.text" = "Chats"; /* Class = "UILabel"; text = "File sharing via HTTP"; ObjectID = "eZJ-fR-LPh"; */ "eZJ-fR-LPh.text" = "Dateiaustausch über HTTP"; /* Class = "UITableViewController"; title = "Experimental"; ObjectID = "FGQ-GL-dYt"; */ "FGQ-GL-dYt.title" = "Experimentell"; /* Class = "UILabel"; text = "Appearance"; ObjectID = "fxY-aA-n0i"; */ "fxY-aA-n0i.text" = "Darstellung"; /* Class = "UIBarButtonItem"; title = "Close"; ObjectID = "G2W-rB-KuE"; */ "G2W-rB-KuE.title" = "Schließen"; /* Class = "UILabel"; text = "2 lines"; ObjectID = "g4v-o5-nXZ"; */ "g4v-o5-nXZ.text" = "2 Zeilen"; /* Class = "UITableViewController"; title = "Notification settings"; ObjectID = "GfS-6V-cuc"; */ "GfS-6V-cuc.title" = "Benachrichtigungseinstellungen"; /* Class = "UILabel"; text = "Contacts"; ObjectID = "hGX-FI-YNj"; */ "hGX-FI-YNj.text" = "Kontakte"; /* Class = "UILabel"; text = "Clear download cache"; ObjectID = "Hu1-2i-RSO"; */ "Hu1-2i-RSO.text" = "Downloadcache löschen"; /* Class = "UILabel"; text = "Media"; ObjectID = "ieE-mK-uEm"; */ "ieE-mK-uEm.text" = "Medien"; /* Class = "UINavigationItem"; title = "Settings"; ObjectID = "jeY-X9-tWB"; */ "jeY-X9-tWB.title" = "Einstellungen"; /* Class = "UILabel"; text = "Auto-authorize contacts"; ObjectID = "MGl-L6-Fs3"; */ "MGl-L6-Fs3.text" = "Kontakte automatisch autorisieren"; /* Class = "UILabel"; text = "Blocked contacts"; ObjectID = "MJ0-kw-1Kl"; */ "MJ0-kw-1Kl.text" = "Gesperrte Kontakte"; /* Class = "UILabel"; text = "\"Hidden\" group"; ObjectID = "P82-B8-768"; */ "P82-B8-768.text" = "Gruppe \"Versteckt\""; /* Class = "UITableViewController"; title = "Chat settings"; ObjectID = "pFo-ox-4gT"; */ "pFo-ox-4gT.title" = "Chateinstellungen"; /* Class = "UILabel"; text = "Groupchats bookmarks sync"; ObjectID = "Tbt-L3-jpa"; */ "Tbt-L3-jpa.text" = "Gruppenchatlesezeichen synchronisieren"; /* Class = "UILabel"; text = "Icon"; ObjectID = "X1h-yo-uZt"; */ "X1h-yo-uZt.text" = "Symbol"; /* Class = "UITableViewController"; title = "Contacts settings"; ObjectID = "xRS-v5-T5C"; */ "xRS-v5-T5C.title" = "Kontakteinstellungen"; /* Class = "UILabel"; text = "Sorting"; ObjectID = "Y0J-o4-ADK"; */ "Y0J-o4-ADK.text" = "Sortierung"; /* Class = "UILabel"; text = "Show link previews"; ObjectID = "ybc-mt-hVG"; */ "ybc-mt-hVG.text" = "Linkvorschau anzeigen"; /* Class = "UILabel"; text = "Experimental"; ObjectID = "YDd-q8-r7f"; */ "YDd-q8-r7f.text" = "Experimentell"; /* Class = "UILabel"; text = "Clear link previews cache"; ObjectID = "Zff-Y8-Nz6"; */ "Zff-Y8-Nz6.text" = "Cache der Linkvorschau löschen"; /* Class = "UILabel"; text = "Encryption"; ObjectID = "ZnP-5r-iGO"; */ "ZnP-5r-iGO.text" = "Verschlüsselung"; /* Class = "UILabel"; text = "Message carbons"; ObjectID = "zwR-jn-5u6"; */ "zwR-jn-5u6.text" = "Message Carbons"; ================================================ FILE: SiskinIM/localization/de.lproj/VoIP.strings ================================================ /* Class = "UITableViewSection"; footerTitle = "Select which account should be used to join channel"; ObjectID = "0d6-UW-iX3"; */ "0d6-UW-iX3.footerTitle" = "Wähle aus, welches Konto für den Beitritt zum Channel verwendet werden soll"; /* Class = "UITableViewSection"; headerTitle = "Account"; ObjectID = "0d6-UW-iX3"; */ "0d6-UW-iX3.headerTitle" = "Konto"; /* Class = "UITextField"; text = "test@example.com"; ObjectID = "9Dn-ee-8WK"; */ "9Dn-ee-8WK.text" = "test@beispiel.de"; ================================================ FILE: SiskinIM/localization/en.lproj/Account.strings ================================================ /* Class = "UILabel"; text = "add email"; ObjectID = "06Y-Cg-0Q3"; */ "06Y-Cg-0Q3.text" = "add email"; /* Class = "UILabel"; text = "Nickname"; ObjectID = "27D-rn-4zp"; */ "27D-rn-4zp.text" = "Nickname"; /* Class = "UITextField"; placeholder = "Country"; ObjectID = "4hs-GL-9fd"; */ "4hs-GL-9fd.placeholder" = "Country"; /* Class = "UILabel"; text = "Host"; ObjectID = "5fg-qS-fyV"; */ "5fg-qS-fyV.text" = "Host"; /* Class = "UILabel"; text = "Change account settings"; ObjectID = "70p-aF-3x5"; */ "70p-aF-3x5.text" = "Change account settings"; /* Class = "UILabel"; text = "Scan QR Code to add me as a buddy"; ObjectID = "7cH-MH-cya"; */ "7cH-MH-cya.text" = "Scan QR Code to add me as a buddy"; /* Class = "UITextField"; placeholder = "Required"; ObjectID = "7KN-S3-8XR"; */ "7KN-S3-8XR.placeholder" = "Required"; /* Class = "UILabel"; text = "From last"; ObjectID = "7td-ty-azW"; */ "7td-ty-azW.text" = "From last"; /* Class = "UITableViewSection"; headerTitle = "Message Archiving"; ObjectID = "9PW-Rb-rop"; */ "9PW-Rb-rop.headerTitle" = "Message Archiving"; /* Class = "UITableViewController"; title = "OMEMO fingerprints"; ObjectID = "A24-eF-tzh"; */ "A24-eF-tzh.title" = "OMEMO fingerprints"; /* Class = "UILabel"; text = "When in Away/XA/DND state"; ObjectID = "aKW-CM-bl2"; */ "aKW-CM-bl2.text" = "When in Away/XA/DND state"; /* Class = "UITextField"; placeholder = "Type"; ObjectID = "Clb-vT-t5A"; Note = "Placeholder text for selection of address/phone type (home/work)"; */ "Clb-vT-t5A.placeholder" = "Type"; /* Class = "UILabel"; text = "Port"; ObjectID = "clx-xZ-SVL"; */ "clx-xZ-SVL.text" = "Port"; /* Class = "UITextField"; placeholder = "City"; ObjectID = "Cmz-iN-c4d"; */ "Cmz-iN-c4d.placeholder" = "City"; /* Class = "UILabel"; text = "Use Direct TLS"; ObjectID = "dtA-fp-y9J"; */ "dtA-fp-y9J.text" = "Use Direct TLS"; /* Class = "UILabel"; text = "Month"; ObjectID = "E11-00-jW5"; */ "E11-00-jW5.text" = "Month"; /* Class = "UINavigationItem"; title = "Settings"; ObjectID = "eIR-sl-fh5"; */ "eIR-sl-fh5.title" = "Settings"; /* Class = "UITableViewSection"; headerTitle = "Encryption"; ObjectID = "Et5-H6-t7C"; */ "Et5-H6-t7C.headerTitle" = "Encryption"; /* Class = "UITextField"; placeholder = "Automatic"; ObjectID = "FBK-JI-crl"; */ "FBK-JI-crl.placeholder" = "Automatic"; /* Class = "UILabel"; text = "add address"; ObjectID = "fRX-fN-yOj"; */ "fRX-fN-yOj.text" = "add address"; /* Class = "UILabel"; text = "Enabled"; ObjectID = "GTa-Ee-HQT"; */ "GTa-Ee-HQT.text" = "Enabled"; /* Class = "UILabel"; text = "OMEMO fingerprint"; ObjectID = "gUr-GY-P3A"; */ "gUr-GY-P3A.text" = "OMEMO fingerprint"; /* Class = "UILabel"; text = "Delete"; ObjectID = "GWw-NK-6oU"; */ "GWw-NK-6oU.text" = "Delete"; /* Class = "UINavigationItem"; title = "Account settings"; ObjectID = "gzC-dB-kIF"; */ "gzC-dB-kIF.title" = "Account settings"; /* Class = "UIBarButtonItem"; title = "Done"; ObjectID = "ijD-Kb-uyj"; */ "ijD-Kb-uyj.title" = "Done"; /* Class = "UILabel"; text = "add phone"; ObjectID = "jAE-vq-Vfj"; */ "jAE-vq-Vfj.text" = "add phone"; /* Class = "UITextField"; placeholder = "Street"; ObjectID = "KgL-GY-IFp"; */ "KgL-GY-IFp.placeholder" = "Street"; /* Class = "UILabel"; text = "Advanced"; ObjectID = "KjA-5f-Mg4"; */ "KjA-5f-Mg4.text" = "Advanced"; /* Class = "UITableViewSection"; headerTitle = "Password"; ObjectID = "LO4-Ys-cek"; */ "LO4-Ys-cek.headerTitle" = "Password"; /* Class = "UIButton"; normalTitle = "Change avatar"; ObjectID = "Mo3-sc-7ss"; */ "Mo3-sc-7ss.normalTitle" = "Change avatar"; /* Class = "UITextField"; placeholder = "Type"; ObjectID = "NK4-tE-QSu"; Note = "Placeholder text for selection of address/phone type (home/work)"; */ "NK4-tE-QSu.placeholder" = "Type"; /* Class = "UITextField"; placeholder = "Code"; ObjectID = "NUu-KT-QM5"; Note = "Postal Code"; */ "NUu-KT-QM5.placeholder" = "Code"; /* Class = "UILabel"; text = "Enable"; ObjectID = "P6B-h8-PVB"; */ "P6B-h8-PVB.text" = "Enable"; /* Class = "UITableViewSection"; footerTitle = "Receive push notifications when your other clients are connected but in Away/XA/DND state"; ObjectID = "PvC-LX-0Sp"; */ "PvC-LX-0Sp.footerTitle" = "Receive push notifications when your other clients are connected but in Away/XA/DND state"; /* Class = "UITableViewSection"; headerTitle = "Push Notifications"; ObjectID = "PvC-LX-0Sp"; */ "PvC-LX-0Sp.headerTitle" = "Push Notifications"; /* Class = "UITableViewSection"; headerTitle = "Encryption"; ObjectID = "qGc-1C-qdh"; */ "qGc-1C-qdh.headerTitle" = "Encryption"; /* Class = "UITextField"; placeholder = "Automatic"; ObjectID = "QX9-Nr-Eq6"; */ "QX9-Nr-Eq6.placeholder" = "Automatic"; /* Class = "UIBarButtonItem"; title = "Next"; ObjectID = "R0m-k2-q64"; */ "R0m-k2-q64.title" = "Next"; /* Class = "UIBarButtonItem"; title = "Skip"; ObjectID = "Rto-c0-DcC"; */ "Rto-c0-DcC.title" = "Skip"; /* Class = "UITableViewSection"; footerTitle = "Enter your account JID"; ObjectID = "SKG-bP-NPK"; */ "SKG-bP-NPK.footerTitle" = "Enter your account JID"; /* Class = "UITableViewSection"; headerTitle = "XMPP ID"; ObjectID = "SKG-bP-NPK"; */ "SKG-bP-NPK.headerTitle" = "XMPP ID"; /* Class = "UITextField"; placeholder = "Phone number"; ObjectID = "T1r-DU-iqs"; */ "T1r-DU-iqs.placeholder" = "Phone number"; /* Class = "UILabel"; text = "Server features"; ObjectID = "t3T-uh-mob"; */ "t3T-uh-mob.text" = "Server features"; /* Class = "UITextField"; placeholder = "Type"; ObjectID = "UQ1-rc-tuY"; Note = "Placeholder text for selection of address/phone type (home/work)"; */ "UQ1-rc-tuY.placeholder" = "Type"; /* Class = "UITableViewSection"; headerTitle = "General"; ObjectID = "v8B-ee-zAk"; */ "v8B-ee-zAk.headerTitle" = "General"; /* Class = "UILabel"; text = "Enabled"; ObjectID = "wbI-5v-vug"; */ "wbI-5v-vug.text" = "Enabled"; /* Class = "UITableViewSection"; headerTitle = "Connectivity"; ObjectID = "we8-hg-uOZ"; */ "we8-hg-uOZ.headerTitle" = "Connectivity"; /* Class = "UITableViewController"; title = "Server Features"; ObjectID = "wW7-3f-Kck"; */ "wW7-3f-Kck.title" = "Server Features"; /* Class = "UILabel"; text = "Title"; ObjectID = "XMr-e2-2o3"; */ "XMr-e2-2o3.text" = "Title"; /* Class = "UILabel"; text = "Disable TLS 1.3"; ObjectID = "xMW-iz-ghG"; */ "xMW-iz-ghG.text" = "Disable TLS 1.3"; /* Class = "UITextField"; placeholder = "Email address"; ObjectID = "ynp-RE-XlY"; */ "ynp-RE-XlY.placeholder" = "Email address"; ================================================ FILE: SiskinIM/localization/en.lproj/Conversation.strings ================================================ /* Class = "UIButton"; normalTitle = "Accept"; ObjectID = "7TV-Kq-bdP"; */ "7TV-Kq-bdP.normalTitle" = "Accept"; /* Class = "UILabel"; text = "Label"; ObjectID = "V6M-WP-Vfp"; */ "V6M-WP-Vfp.text" = "Label"; ================================================ FILE: SiskinIM/localization/en.lproj/Groupchat.strings ================================================ /* Class = "UILabel"; text = "Notifications"; ObjectID = "02f-aw-3Zd"; */ "02f-aw-3Zd.text" = "Notifications"; /* Class = "UITableViewSection"; footerTitle = "Push notification support depends on the XMPP server which you are using and may not work in some cases even if it's supported and enabled on the group chat"; ObjectID = "bRu-vo-fpu"; */ "bRu-vo-fpu.footerTitle" = "Push notification support depends on the XMPP server which you are using and may not work in some cases even if it's supported and enabled on the group chat"; /* Class = "UITableViewSection"; headerTitle = "Settings"; ObjectID = "bRu-vo-fpu"; */ "bRu-vo-fpu.headerTitle" = "Settings"; /* Class = "UILabel"; text = "Encryption"; ObjectID = "DQk-sn-hKj"; */ "DQk-sn-hKj.text" = "Encryption"; /* Class = "UILabel"; text = "When mentioned"; ObjectID = "exK-ZH-jpA"; */ "exK-ZH-jpA.text" = "When mentioned"; /* Class = "UITableViewController"; title = "Room details"; ObjectID = "IGA-uE-mHb"; Note = "View with details of a room"; */ "IGA-uE-mHb.title" = "Room details"; /* Class = "UITableViewSection"; headerTitle = "Subject"; ObjectID = "iYv-zL-tZT"; */ "iYv-zL-tZT.headerTitle" = "Subject"; /* Class = "UILabel"; text = "Push notifications"; ObjectID = "jMz-qR-fJ6"; */ "jMz-qR-fJ6.text" = "Push notifications"; /* Class = "UITableViewController"; title = "Invite to chat"; ObjectID = "SOJ-3n-8YB"; */ "SOJ-3n-8YB.title" = "Invite to chat"; /* Class = "UILabel"; text = "Attachments"; ObjectID = "wBw-1J-Xau"; */ "wBw-1J-Xau.text" = "Attachments"; ================================================ FILE: SiskinIM/localization/en.lproj/Info.strings ================================================ /* Class = "UITableViewSection"; headerTitle = "Application"; ObjectID = "1Kk-Xc-9Ce"; */ "1Kk-Xc-9Ce.headerTitle" = "Application"; /* Class = "UILabel"; text = "Website"; ObjectID = "4wJ-8E-L1J"; */ "4wJ-8E-L1J.text" = "Website"; /* Class = "UILabel"; text = "Website"; ObjectID = "56G-hc-AcV"; */ "56G-hc-AcV.text" = "Website"; /* Class = "UITableViewController"; title = "Get in touch"; ObjectID = "5mK-5u-qus"; */ "5mK-5u-qus.title" = "Get in touch"; /* Class = "UITableViewSection"; headerTitle = "Company"; ObjectID = "kdx-K5-gSV"; */ "kdx-K5-gSV.headerTitle" = "Company"; /* Class = "UILabel"; text = "Version: 5.0"; ObjectID = "lRd-ky-eXs"; */ "lRd-ky-eXs.text" = "Version: 5.0"; /* Class = "UILabel"; text = "XMPP Channel"; ObjectID = "U4z-jz-alK"; */ "U4z-jz-alK.text" = "XMPP Channel"; ================================================ FILE: SiskinIM/localization/en.lproj/Localizable.strings ================================================ /* section label, device memory */ "%@ memory" = "%@ memory"; /* no. of lines of messages preview label */ "%d lines of preview" = "%d lines of preview"; /* conversation log groupchat direction label */ "(private message)" = "(private message)"; /* conversation log label */ "(this message has been removed)" = "(this message has been removed)"; /* no. of lines of messages preview label */ "1 line of preview" = "1 line of preview"; /* ssl certificate info - issue part */ "\nissued by\n%@\n with fingerprint\n%@" = "\nissued by\n%1$@\n with fingerprint\n%2$@"; /* button label */ "Accept" = "Accept"; /* channel join status view label */ "Account" = "Account"; /* alert title */ "Account removal" = "Account removal"; /* section label */ "Accounts" = "Accounts"; /* cell label */ "Add account" = "Add account"; /* action label */ "Add contact" = "Add contact"; /* button label */ "Add existing" = "Add existing"; /* contact details section vcard section label */ "Addresses" = "Addresses"; /* filter scope */ "All" = "All"; /* alert message - unblock communication with server */ "All communication with users from %@ is blocked. Do you wish to unblock communication with this server?" = "All communication with users from %@ is blocked. Do you wish to unblock communication with this server?"; /* alert body */ "All messages will be deleted and all participants will be kicked out. Are you sure?" = "All messages will be deleted and all participants will be kicked out. Are you sure?"; /* conversation notifications status */ "Always" = "Always"; /* attachemt label for conversations list */ "Attachment" = "Attachment"; /* section label */ "Attachments" = "Attachments"; /* action label */ "Audio call" = "Audio call"; /* notification warning about authentication failure */ "Authentication for account %@ failed: %@" = "Authentication for account %1$@ failed: %2$@"; /* alert title body */ "Authentication for account %@ failed: %@\nVerify provided account password." = "Authentication for account %1$@ failed: %2$@\nVerify provided account password."; /* alert title */ "Authentication issue" = "Authentication issue"; /* appearance type */ "Auto" = "Auto"; /* audio output selection channel join status view label */ "Automatic" = "Automatic"; /* filter scope */ "Available" = "Available"; /* presence status user status */ "Away" = "Away"; /* action label */ "Ban user" = "Ban user"; /* alert title */ "Banning user" = "Banning user"; /* alert title */ "Banning user %@ failed" = "Banning user %@ failed"; /* user status */ "Be right back" = "Be right back"; /* alert body */ "Before changing roster you need to connect to server. Do you wish to do this now?" = "Before changing roster you need to connect to server. Do you wish to do this now?"; /* vcard field label */ "Birthday" = "Birthday"; /* button label */ "Block" = "Block"; /* button label */ "Block and report" = "Block and report"; /* context menu item */ "Block contact" = "Block contact"; /* action */ "Block participant" = "Block participant"; /* context menu item */ "Block server" = "Block server"; /* user status - contact blocked */ "Blocked" = "Blocked"; /* channel participants view operation */ "Blocking…" = "Blocking…"; /* search bar scope */ "By name" = "By name"; /* search bar scope */ "By status" = "By status"; /* call state label */ "Call ended" = "Call ended"; /* alert title */ "Call failed" = "Call failed"; /* button label */ "Cancel" = "Cancel"; /* alert title */ "Certificate issue" = "Certificate issue"; /* button label */ "Change" = "Change"; /* button label */ "Change avatar" = "Change avatar"; /* alert title button label */ "Change subject" = "Change subject"; /* alert title alert window title */ "Channel destruction failed!" = "Channel destruction failed!"; /* alert title */ "Channel destuction" = "Channel destuction"; /* action label presence status */ "Chat" = "Chat"; /* channel create view operation label channel join view operation label channel settings view opeartion label */ "Checking…" = "Checking…"; /* button label */ "Close" = "Close"; /* channel join status view label */ "Component" = "Component"; /* call state label */ "Connecting…" = "Connecting…"; /* error notification message */ "Connection to server %@ failed" = "Connection to server %@ failed"; /* attachment cell context action context action context action label */ "Copy" = "Copy"; /* context action label */ "Correct…" = "Correct…"; /* alert body */ "Could not create channel on the server. Got following error: %@" = "Could not create channel on the server. Got following error: %@"; /* alert body */ "Could not delete account as it was not possible to connect to the XMPP server. Please try again later." = "Could not delete account as it was not possible to connect to the XMPP server. Please try again later."; /* sharing error */ "Could not detect MIME type of a file." = "Could not detect MIME type of a file."; /* alert title */ "Could not join" = "Could not join"; /* alert body */ "Could not join newly created channel '%@' on the server. Got following error: %@" = "Could not join newly created channel '%@' on the server. Got following error: %@"; /* alert body */ "Could not join room. Reason:\n%@" = "Could not join room. Reason:\n%@"; /* alert body */ "Could not rename group chat. The server responded with an error: %@" = "Could not rename group chat. The server responded with an error: %@"; /* sharing error */ "Could not retrieve file size." = "Could not retrieve file size."; /* alert body */ "Could not set group chat avatar. The server responded with an error: %@" = "Could not set group chat avatar. The server responded with an error: %@"; /* alert title */ "Could not update channel details" = "Could not update channel details"; /* button label */ "Create" = "Create"; /* button label */ "Create bookmark" = "Create bookmark"; /* label for chats list new converation action */ "Create meeting" = "Create meeting"; /* button label */ "Create new" = "Create new"; /* cell sublabel */ "Create new or add existing account" = "Create new or add existing account"; /* channel join view operation label */ "Creating channel…" = "Creating channel…"; /* appearance type */ "Dark" = "Dark"; /* encryption default label encryption setting value */ "Default" = "Default"; /* default roster group */ "Default " = "Default "; /* alert body */ "Default account is not connected. Please select a different account." = "Default account is not connected. Please select a different account."; /* action label attachment cell context action button label context action */ "Delete" = "Delete"; /* alert title */ "Delete channel?" = "Delete channel?"; /* button label */ "Destroy" = "Destroy"; /* alert title */ "Details" = "Details"; /* label for omemo device id */ "Device: %@" = "Device: %@"; /* button label */ "Disable autojoin" = "Disable autojoin"; /* button label */ "Dismiss" = "Dismiss"; /* section label */ "Display" = "Display"; /* label for chat marker */ "Displayed" = "Displayed"; /* presence status user status */ "Do not disturb" = "Do not disturb"; /* alert body */ "Do you want to ban user %@?" = "Do you want to ban user %@?"; /* alert body */ "Do you wish to publish this photo as avatar?" = "Do you wish to publish this photo as avatar?"; /* alert body */ "Do you wish to register a new account %@?" = "Do you wish to register a new account %@?"; /* alert body */ "Do you wish to register a new account at %@?" = "Do you wish to register a new account at %@?"; /* alert body */ "Do you wish to subscribe to \n%@\non account %@" = "Do you wish to subscribe to \n%1$@\non account %2$@"; /* attachment cell context action confirmation dialog title */ "Download" = "Download"; /* alert title */ "Download storage" = "Download storage"; /* memory usage label */ "Downloads" = "Downloads"; /* action label */ "Edit" = "Edit"; /* contact details section vcard section label */ "Emails" = "Emails"; /* button label */ "Enable" = "Enable"; /* button label */ "Enable autojoin" = "Enable autojoin"; /* option description */ "Enabling message synchronization will enable message archiving on the server" = "Enabling message synchronization will enable message archiving on the server"; /* contact details section */ "Encryption" = "Encryption"; /* alert body */ "Enter default nickname to use in chats" = "Enter default nickname to use in chats"; /* alert body */ "Enter message to send to: %@" = "Enter message to send to: %@"; /* placeholder */ "Enter message…" = "Enter message…"; /* alert body */ "Enter new name for group chat" = "Enter new name for group chat"; /* alert body */ "Enter new subject for group chat" = "Enter new subject for group chat"; /* alert body */ "Enter status message" = "Enter status message"; /* alert title */ "Error" = "Error"; /* error description message - detail */ "Error code" = "Error code"; /* alert title */ "Error occurred" = "Error occurred"; /* option to remove all data from local storage */ "Everything" = "Everything"; /* presence status */ "Extended away" = "Extended away"; /* alert title alert window title */ "Failure" = "Failure"; /* vcard field label */ "Family name" = "Family name"; /* sharing error */ "Feature not supported by XMPP server" = "Feature not supported by XMPP server"; /* file size label */ "File - %@" = "File - %@"; /* confirmation dialog body */ "File is not available locally. Should it be downloaded?" = "File is not available locally. Should it be downloaded?"; /* sharing error */ "File is too big to share" = "File is too big to share"; /* section label */ "Fingerprint of this device" = "Fingerprint of this device"; /* section label */ "For account" = "For account"; /* memory usage label */ "Free" = "Free"; /* user status */ "Free for chat" = "Free for chat"; /* conversation log groupchat direction label */ "From" = "From"; /* conversation view input field placeholder */ "from %@…" = "from %@…"; /* vcard field label */ "Full name" = "Full name"; /* section label */ "General" = "General"; /* vcard field label */ "Given name" = "Given name"; /* video quality */ "High" = "High"; /* video quality */ "Highest" = "Highest"; /* address type address type label */ "Home" = "Home"; /* alert body push notifications option description */ "If enabled, you will receive notifications of new messages or calls even if SiskinIM is in background. SiskinIM servers will forward those notifications for you from XMPP servers." = "If enabled, you will receive notifications of new messages or calls even if SiskinIM is in background. SiskinIM servers will forward those notifications for you from XMPP servers."; /* section footer */ "If you don't know any XMPP server domain names, then select one of our trusted servers." = "If you don't know any XMPP server domain names, then select one of our trusted servers."; /* alert title */ "Incoming call" = "Incoming call"; /* alert body */ "Incoming call from %@" = "Incoming call from %@"; /* action label */ "Info" = "Info"; /* section label */ "Initial synchronization" = "Initial synchronization"; /* muc error reason */ "Invalid password" = "Invalid password"; /* invitation label for chats list */ "Invitation" = "Invitation"; /* conversation log invitation to channel label */ "Invitation to channel %@" = "Invitation to channel %@"; /* muc invitation notification */ "Invitation to groupchat %@" = "Invitation to groupchat %@"; /* button label */ "Invite" = "Invite"; /* button label */ "Invite…" = "Invite…"; /* error message */ "It was not possible to access camera or microphone. Please check privacy settings" = "It was not possible to access camera or microphone. Please check privacy settings"; /* sharing error */ "It was not possible to access the file." = "It was not possible to access the file."; /* push notifications registration failure message */ "It was not possible to contact push notification component." = "It was not possible to contact push notification component."; /* push notifications registration failure message */ "It was not possible to contact push notification component.\nTry again later." = "It was not possible to contact push notification component.\nTry again later."; /* push notifications registration failure message */ "It was not possible to contact push notification component: %@" = "It was not possible to contact push notification component: %@"; /* error message */ "It was not possible to contact XMPP server and sign in." = "It was not possible to contact XMPP server and sign in."; /* alert body */ "It was not possible to create a meeting. Server returned an error: %@" = "It was not possible to create a meeting. Server returned an error: %@"; /* alert body alert window message */ "It was not possible to destroy channel %@. Server returned an error: %@" = "It was not possible to destroy channel %1$@. Server returned an error: %2$@"; /* error message */ "It was not possible to establish call" = "It was not possible to establish call"; /* alert body */ "It was not possible to grant selected users access to the meeting. Received an error: %@" = "It was not possible to grant selected users access to the meeting. Received an error: %@"; /* alert body */ "It was not possible to initiate a call: %@" = "It was not possible to initiate a call: %@"; /* alert button */ "It was not possible to join a channel. The server returned an error: %@" = "It was not possible to join a channel. The server returned an error: %@"; /* error description message */ "It was not possible to modify account." = "It was not possible to modify account."; /* alert title */ "It was not possible to save account details" = "It was not possible to save account details"; /* alert body */ "It was not possible to save account details: %@" = "It was not possible to save account details: %@"; /* alert title body */ "It was not possible to save account details: %@ Please try again later." = "It was not possible to save account details: %@ Please try again later."; /* unsent messages notification */ "It was not possible to send %d messages. Open the app to retry" = "It was not possible to send %d messages. Open the app to retry"; /* message encryption failure */ "It was not possible to send encrypted message due to encryption error" = "It was not possible to send encrypted message due to encryption error"; /* button label */ "Join" = "Join"; /* label for chats list new converation action */ "Join group chat" = "Join group chat"; /* action label */ "Join room" = "Join room"; /* channel status label */ "Joined" = "Joined"; /* channel join view operation label muc room status */ "Joining…" = "Joining…"; /* report spam action */ "Just block" = "Just block"; /* no OMEMO key - not generated yet */ "Key not generated!" = "Key not generated!"; /* button label */ "Kick out" = "Kick out"; /* option description */ "Large value may increase inital synchronization time" = "Large value may increase inital synchronization time"; /* button label */ "Leave" = "Leave"; /* leaving channel title */ "Leaving channel" = "Leaving channel"; /* appearance type */ "Light" = "Light"; /* option description */ "Limits the size of the files sent to you which may be automatically downloaded" = "Limits the size of the files sent to you which may be automatically downloaded"; /* memory usage label */ "Link previews" = "Link previews"; /* section label */ "List of messages" = "List of messages"; /* attachemt label for conversations list */ "Location" = "Location"; /* error message */ "Login and password do not match." = "Login and password do not match."; /* video quality */ "Low" = "Low"; /* muc error reason */ "Maximum number of users exceeded" = "Maximum number of users exceeded"; /* me label for conversation log */ "Me" = "Me"; /* video quality */ "Medium" = "Medium"; /* alert title */ "Meeting ended" = "Meeting ended"; /* alert body */ "Meeting has ended" = "Meeting has ended"; /* muc error reason */ "Membership is required to access the room" = "Membership is required to access the room"; /* message decryption error message encryption failure */ "Message decryption failed! Error code: %d" = "Message decryption failed! Error code: %d"; /* alert body */ "Message moderation failed!" = "Message moderation failed!"; /* section label */ "Message synchronization" = "Message synchronization"; /* message encryption failure */ "Message was not encrypted for this device" = "Message was not encrypted for this device"; /* message decryption error */ "Message was not encrypted for this device." = "Message was not encrypted for this device."; /* section label */ "Messages" = "Messages"; /* alert title */ "Metadata storage" = "Metadata storage"; /* alert title */ "Missed call" = "Missed call"; /* alert body */ "Missed incoming call from %@" = "Missed incoming call from %@"; /* context action label */ "Moderate" = "Moderate"; /* list of users with this role */ "Moderators" = "Moderators"; /* synchronization period value */ "Month" = "Month"; /* attachment cell context action context action label */ "More…" = "More…"; /* conversation notifications status */ "Muted" = "Muted"; /* call state label */ "New call" = "New call"; /* notification of incoming message on locked screen */ "New message" = "New message"; /* new message without content notification */ "New message!" = "New message!"; /* label for chats list new converation action */ "New private group chat" = "New private group chat"; /* label for chats list new converation action */ "New public group chat" = "New public group chat"; /* alert title */ "Nickname" = "Nickname"; /* muc error reason */ "Nickname already in use" = "Nickname already in use"; /* muc error reason */ "Nickname is locked down" = "Nickname is locked down"; /* button label */ "No" = "No"; /* attachments view label */ "No attachments" = "No attachments"; /* channel join status view label encryption type encyption option list of users with this role */ "None" = "None"; /* channel status label muc room status label */ "Not connected" = "Not connected"; /* channel status label */ "Not joined" = "Not joined"; /* action label */ "Nothing" = "Nothing"; /* muc room status user status */ "Offline" = "Offline"; /* action label Button button label button lable */ "OK" = "OK"; /* option to remove all data from local storage option to remove data older than 7 days */ "Older than 7 days" = "Older than 7 days"; /* encryption option encryption type */ "OMEMO" = "OMEMO"; /* muc room status presence status user status */ "Online" = "Online"; /* action label */ "Open chat" = "Open chat"; /* alert title */ "Open URL" = "Open URL"; /* vcard field label */ "Organization" = "Organization"; /* vcard field label */ "Organization role" = "Organization role"; /* video quality */ "Original" = "Original"; /* selection warning */ "Original quality will share image in the format in which it is stored on your phone and it may not be supported by every device." = "Original quality will share image in the format in which it is stored on your phone and it may not be supported by every device."; /* selection warning */ "Original quality will share video in the format in which video is stored on your phone and it may not be supported by every device." = "Original quality will share video in the format in which video is stored on your phone and it may not be supported by every device."; /* memory usage label */ "Other apps" = "Other apps"; /* section label */ "Other devices fingerprints" = "Other devices fingerprints"; /* list of users with this role */ "Participants" = "Participants"; /* button label */ "Pass ownership" = "Pass ownership"; /* contact details section vcard section label */ "Phones" = "Phones"; /* voice message state */ "Playing…" = "Playing…"; /* instruction to fill out the form */ "Please fill this form" = "Please fill this form"; /* alert title */ "Please launch application from the home screen before continuing." = "Please launch application from the home screen before continuing."; /* sharing error */ "Please try again later." = "Please try again later."; /* section label */ "Preferred domain name" = "Preferred domain name"; /* operation label */ "Preparing…" = "Preparing…"; /* attachment cell context action context action */ "Preview" = "Preview"; /* action label */ "Private message" = "Private message"; /* account registration error */ "Provided values are not acceptable" = "Provided values are not acceptable"; /* alert title */ "Push notifications" = "Push notifications"; /* alert title */ "Push Notifications" = "Push Notifications"; /* alert body */ "Push notifications are enabled for %@. They need to be disabled before account can be removed and it is not possible to at this time. Please try again later." = "Push notifications are enabled for %@. They need to be disabled before account can be removed and it is not possible to at this time. Please try again later."; /* push notifications registration failure message */ "Push notifications not available" = "Push notifications not available"; /* section label */ "Quality of uploaded media" = "Quality of uploaded media"; /* synchronization period value */ "Quarter" = "Quarter"; /* alert title */ "Question" = "Question"; /* label for chat marker */ "Received" = "Received"; /* presence subscription request notification */ "Received presence subscription request from %@" = "Received presence subscription request from %@"; /* alert title body */ "Received presence subscription request from\n%@\non account %@" = "Received presence subscription request from\n%1$@\non account %2$@"; /* voice message state */ "Recorded: %@" = "Recorded: %@"; /* voice message state */ "Recording…" = "Recording…"; /* voice message state */ "Recording… %@" = "Recording… %@"; /* channel block users view operation channel edit info operation channel participants view operation */ "Refreshing…" = "Refreshing…"; /* button label */ "Register" = "Register"; /* alert title */ "Registering account" = "Registering account"; /* alert title */ "Registration failure" = "Registration failure"; /* account registration error */ "Registration is not supported by this server" = "Registration is not supported by this server"; /* button label */ "Reject" = "Reject"; /* alert body */ "Remote server returned an error: %@" = "Remote server returned an error: %@"; /* option description */ "Removal of cached attachments may lead to increased usage of network, if attachment may need to be redownloaded, or to lost files, if they are no longer available at the server." = "Removal of cached attachments may lead to increased usage of network, if attachment may need to be redownloaded, or to lost files, if they are no longer available at the server."; /* alert body */ "Remove account from application?" = "Remove account from application?"; /* button label */ "Remove bookmark" = "Remove bookmark"; /* button label */ "Remove from application" = "Remove from application"; /* button label */ "Remove from server" = "Remove from server"; /* button label */ "Rename" = "Rename"; /* alert title button label */ "Rename chat" = "Rename chat"; /* context action label */ "Reply…" = "Reply…"; /* context action label */ "Report & block…" = "Report & block…"; /* report abuse action */ "Report abuse" = "Report abuse"; /* report spam action */ "Report spam" = "Report spam"; /* button label */ "Resend" = "Resend"; /* context action label */ "Retract" = "Retract"; /* call state label */ "Ringing…" = "Ringing…"; /* alert title */ "Room %@" = "Room %@"; /* muc error reason */ "Room is locked" = "Room is locked"; /* alert body */ "Room was created and joined but room was not properly configured. Got following error: %@" = "Room was created and joined but room was not properly configured. Got following error: %@"; /* search bar placeholder */ "Search channels" = "Search channels"; /* placeholder for location selection search bar */ "Search for places" = "Search for places"; /* placeholder */ "Search to add…" = "Search to add…"; /* alert body */ "Select account to open chat from" = "Select account to open chat from"; /* selection information */ "Select appearance" = "Select appearance"; /* selection application icon information */ "Select application icon" = "Select application icon"; /* title for multiple contact selection */ "Select contacts" = "Select contacts"; /* selection information */ "Select default conversation encryption" = "Select default conversation encryption"; /* location selection window title */ "Select location" = "Select location"; /* selection description */ "Select period of messages to be synchronized" = "Select period of messages to be synchronized"; /* button label photo selection action */ "Select photo" = "Select photo"; /* media quality selection instruction */ "Select quality" = "Select quality"; /* selection description */ "Select quality of the image to use for sharing" = "Select quality of the image to use for sharing"; /* selection description */ "Select quality of the video to use for sharing" = "Select quality of the video to use for sharing"; /* view title */ "Select recipients" = "Select recipients"; /* alert title */ "Select status" = "Select status"; /* button label */ "Send" = "Send"; /* alert title */ "Send message" = "Send message"; /* channel invitations view operation */ "Sending invitations…" = "Sending invitations…"; /* operation label */ "Sending…" = "Sending…"; /* sharing error */ "Server did not confirm file upload correctly." = "Server did not confirm file upload correctly."; /* ssl certificate alert dialog body */ "Server for domain %@ provided invalid certificate for %@\n with fingerprint\n%@%@.\nDo you trust this certificate?" = "Server for domain %1$@ provided invalid certificate for %2$@\n with fingerprint\n%3$@%4$@.\nDo you trust this certificate?"; /* alert title - unblock communication with server */ "Server is blocked" = "Server is blocked"; /* alert body */ "Server of selected account does not provide support for hosting meetings. Please select a different account." = "Server of selected account does not provide support for hosting meetings. Please select a different account."; /* account registration error alert body */ "Server returned an error: %@" = "Server returned an error: %@"; /* account registration error */ "Service is not available at this time." = "Service is not available at this time."; /* alert title */ "Service unavailable" = "Service unavailable"; /* button label */ "Set" = "Set"; /* contact details section section label */ "Settings" = "Settings"; /* attachment cell context action context action context action label */ "Share…" = "Share…"; /* section label */ "Sharing" = "Sharing"; /* alert body */ "Sharing feature with HTTP upload is disabled within application. To use this feature you need to enable sharing with HTTP upload in application" = "Sharing feature with HTTP upload is disabled within application. To use this feature you need to enable sharing with HTTP upload in application"; /* alert body */ "Should account be removed from server as well?" = "Should account be removed from server as well?"; /* context action label */ "Show map" = "Show map"; /* notifications from unknown description */ "Show notifications from people not in your contact list" = "Show notifications from people not in your contact list"; /* App icon */ "Simple" = "Simple"; /* audio output label */ "Speaker" = "Speaker"; /* alert title */ "Start chatting" = "Start chatting"; /* alert title section label */ "Status" = "Status"; /* alert title */ "Subscribe to %@" = "Subscribe to %@"; /* alert title */ "Subscription request" = "Subscription request"; /* button label */ "Switch audio" = "Switch audio"; /* button label */ "Switch camera" = "Switch camera"; /* button label photo selection action */ "Take photo" = "Take photo"; /* report user message */ "The user %@ will be blocked. Should it be reported as well?" = "The user %@ will be blocked. Should it be reported as well?"; /* alert message */ "There is no service supporting channels for domain %@" = "There is no service supporting channels for domain %@"; /* message encryption failure */ "There is no trusted device to send message to" = "There is no trusted device to send message to"; /* alert body */ "This room is not capable of sending encrypted messages. Please change encryption settings to be able to send messages" = "This room is not capable of sending encrypted messages. Please change encryption settings to be able to send messages"; /* conversation log groupchat direction label */ "To" = "To"; /* section label */ "Trusted servers" = "Trusted servers"; /* error recovery suggestion */ "Try again. If removal failed, try accessing Keychain to update account credentials manually." = "Try again. If removal failed, try accessing Keychain to update account credentials manually."; /* synchronization period value */ "Two weeks" = "Two weeks"; /* action button label */ "Unblock" = "Unblock"; /* context menu action */ "Unblock server" = "Unblock server"; /* alert body */ "Unknown error occurred" = "Unknown error occurred"; /* unknown file label */ "Unknown file" = "Unknown file"; /* allowed size of file to download */ "Unlimited" = "Unlimited"; /* conversation log label */ "Unread messages" = "Unread messages"; /* channel block users view operation channel edit info operation refresh conrol label */ "Updating…" = "Updating…"; /* alert title */ "Upload failed" = "Upload failed"; /* sharing error */ "Upload to HTTP server failed." = "Upload to HTTP server failed."; /* option description */ "Used image and video quality may impact storage and network usage" = "Used image and video quality may impact storage and network usage"; /* alert body */ "User avatar publication failed.\nReason: %@" = "User avatar publication failed.\nReason: %@"; /* muc error reason */ "User is banned" = "User is banned"; /* account registration error */ "User with provided username already exists" = "User with provided username already exists"; /* account info label */ "using %@" = "using %@"; /* alert body */ "VCard publication failed: %@" = "VCard publication failed: %@"; /* version of the app */ "Version: %@" = "Version: %@"; /* action label */ "Video call" = "Video call"; /* list of users with this role */ "Visitors" = "Visitors"; /* alert title */ "Warning" = "Warning"; /* alert body used space label */ "We are using %@ of storage." = "We are using %@ of storage."; /* synchronization period value */ "Week" = "Week"; /* alert body */ "What do you want to do with %@?" = "What do you want to do with %@?"; /* conversation notifications status */ "When mentioned" = "When mentioned"; /* alert body */ "When you share files using HTTP, they are uploaded to HTTP server with unique URL. Anyone who knows the unique URL to the file is able to download it.\nDo you wish to enable?" = "When you share files using HTTP, they are uploaded to HTTP server with unique URL. Anyone who knows the unique URL to the file is able to download it.\nDo you wish to enable?"; /* alert body */ "When you share files, they are uploaded to HTTP server with unique URL. Anyone who knows the unique URL to the file is able to download it.\nDo you wish to proceed?" = "When you share files, they are uploaded to HTTP server with unique URL. Anyone who knows the unique URL to the file is able to download it.\nDo you wish to proceed?"; /* address type address type label */ "Work" = "Work"; /* synchronization period value */ "Year" = "Year"; /* button label */ "Yes" = "Yes"; /* alert body */ "You are about to destroy channel %@. This will remove the channel on the server, remove remote history archive, and kick out all participants. Are you sure?" = "You are about to destroy channel %@. This will remove the channel on the server, remove remote history archive, and kick out all participants. Are you sure?"; /* error label */ "You are invied to join conversation at %@" = "You are invied to join conversation at %@"; /* alert body */ "You are not connected to room.\nPlease wait reconnection to room" = "You are not connected to room.\nPlease wait reconnection to room"; /* alert body */ "You are not joined to the channel." = "You are not joined to the channel."; /* leaving channel text */ "You are the last person with ownership of this channel. Please decide what to do with the channel." = "You are the last person with ownership of this channel. Please decide what to do with the channel."; /* push notifications not allowed warning */ "You need to allow application to show notifications and for background refresh." = "You need to allow application to show notifications and for background refresh."; /* alert body */ "You've left there room %@ and push notifications for this room were disabled!\nYou may need to reenable them on other devices." = "You've left there room %@ and push notifications for this room were disabled!\nYou may need to reenable them on other devices."; /* search location pin label */ "Your location" = "Your location"; ================================================ FILE: SiskinIM/localization/en.lproj/MIX.strings ================================================ /* Class = "UITextField"; placeholder = "Automatic"; ObjectID = "19A-3H-7QN"; */ "19A-3H-7QN.placeholder" = "Automatic"; /* Class = "UILabel"; text = "Use MIX"; ObjectID = "4P1-zT-Par"; */ "4P1-zT-Par.text" = "Use MIX"; /* Class = "UITableViewSection"; footerTitle = "Enter domain name of a component with channel or leave blank to automatically detect components with channels"; ObjectID = "94L-uC-ldZ"; */ "94L-uC-ldZ.footerTitle" = "Enter domain name of a component with channel or leave blank to automatically detect components with channels"; /* Class = "UITableViewSection"; headerTitle = "Component domain"; ObjectID = "94L-uC-ldZ"; */ "94L-uC-ldZ.headerTitle" = "Component domain"; /* Class = "UILabel"; text = "Create"; ObjectID = "9Ww-8v-Dml"; */ "9Ww-8v-Dml.text" = "Create"; /* Class = "UITableViewSection"; headerTitle = "Experimental"; ObjectID = "aqR-Sm-2re"; */ "aqR-Sm-2re.headerTitle" = "Experimental"; /* Class = "UINavigationItem"; title = "Select channel"; ObjectID = "C5U-9C-poe"; */ "C5U-9C-poe.title" = "Select channel"; /* Class = "UITableViewSection"; headerTitle = "Bookmark"; ObjectID = "cOt-Oi-tab"; */ "cOt-Oi-tab.headerTitle" = "Bookmark"; /* Class = "UIBarButtonItem"; title = "Item"; ObjectID = "cv8-VS-8c6"; */ "cv8-VS-8c6.title" = "Item"; /* Class = "UILabel"; text = "Autojoin"; ObjectID = "da6-M6-5nm"; */ "da6-M6-5nm.text" = "Autojoin"; /* Class = "UILabel"; text = "Notifications"; ObjectID = "fFa-pN-ndo"; */ "fFa-pN-ndo.text" = "Notifications"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "gNa-Cz-T88"; Note = "Placeholder for a field to notify user that it needs to be filled"; */ "gNa-Cz-T88.placeholder" = "required"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "Jiu-Fd-AsM"; */ "Jiu-Fd-AsM.placeholder" = "required"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "kD7-lz-IEK"; Note = "Placeholder for a field to notify user that it needs to be filled"; */ "kD7-lz-IEK.placeholder" = "required"; /* Class = "UITableViewController"; title = "Channel details"; ObjectID = "ke4-WK-unt"; */ "ke4-WK-unt.title" = "Channel details"; /* Class = "UIBarButtonItem"; title = "Invite"; ObjectID = "Kre-sS-2vH"; */ "Kre-sS-2vH.title" = "Invite"; /* Class = "UITextField"; placeholder = "Description"; ObjectID = "MsF-6z-TY3"; */ "MsF-6z-TY3.placeholder" = "Description"; /* Class = "UINavigationItem"; title = "Participants"; ObjectID = "mW8-st-X8N"; */ "mW8-st-X8N.title" = "Participants"; /* Class = "UITableViewSection"; headerTitle = "Access"; ObjectID = "mXt-Xt-Bj5"; */ "mXt-Xt-Bj5.headerTitle" = "Access"; /* Class = "UILabel"; text = "Attachments"; ObjectID = "NWM-d4-jmq"; */ "NWM-d4-jmq.text" = "Attachments"; /* Class = "UITableViewSection"; headerTitle = "Nickname"; ObjectID = "NyO-PD-t9d"; */ "NyO-PD-t9d.headerTitle" = "Nickname"; /* Class = "UILabel"; text = "Delete channel"; ObjectID = "O9e-sP-5IO"; */ "O9e-sP-5IO.text" = "Delete channel"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "oIZ-xz-SzZ"; */ "oIZ-xz-SzZ.placeholder" = "required"; /* Class = "UIBarButtonItem"; title = "Join"; ObjectID = "Pgv-Uz-ZgP"; */ "Pgv-Uz-ZgP.title" = "Join"; /* Class = "UIBarButtonItem"; title = "Next"; ObjectID = "pZC-YZ-jlg"; */ "pZC-YZ-jlg.title" = "Next"; /* Class = "UITableViewSection"; headerTitle = "Password"; ObjectID = "qB9-Eq-3RT"; */ "qB9-Eq-3RT.headerTitle" = "Password"; /* Class = "UITextField"; placeholder = "Name"; ObjectID = "r60-FV-hoE"; */ "r60-FV-hoE.placeholder" = "Name"; /* Class = "UILabel"; text = "Change"; ObjectID = "Sso-1F-PI3"; */ "Sso-1F-PI3.text" = "Change"; /* Class = "UITableViewSection"; footerTitle = "Select which account should be used to join channel"; ObjectID = "SUF-pg-qbn"; */ "SUF-pg-qbn.footerTitle" = "Select which account should be used to join channel"; /* Class = "UITableViewSection"; headerTitle = "Account"; ObjectID = "SUF-pg-qbn"; */ "SUF-pg-qbn.headerTitle" = "Account"; /* Class = "UITableViewSection"; footerTitle = "Notification support and filtering depends on the XMPP server which you are using and may not work in some cases even if it's enabled here."; ObjectID = "Ta8-dG-cwn"; */ "Ta8-dG-cwn.footerTitle" = "Notification support and filtering depends on the XMPP server which you are using and may not work in some cases even if it's enabled here."; /* Class = "UITableViewSection"; headerTitle = "Settings"; ObjectID = "Ta8-dG-cwn"; */ "Ta8-dG-cwn.headerTitle" = "Settings"; /* Class = "UITableViewSection"; footerTitle = "ID of a channel used for joining (localpart of a JID)"; ObjectID = "u6K-qu-Pf5"; */ "u6K-qu-Pf5.footerTitle" = "ID of a channel used for joining (localpart of a JID)"; /* Class = "UITableViewSection"; headerTitle = "Channel ID"; ObjectID = "u6K-qu-Pf5"; */ "u6K-qu-Pf5.headerTitle" = "Channel ID"; /* Class = "UINavigationItem"; title = "Blocked"; ObjectID = "uMJ-O9-BGV"; */ "uMJ-O9-BGV.title" = "Blocked"; /* Class = "UIBarButtonItem"; title = "Next"; ObjectID = "uX9-lh-BwU"; */ "uX9-lh-BwU.title" = "Next"; /* Class = "UITableViewSection"; headerTitle = "Channel name"; ObjectID = "vcg-og-awz"; */ "vcg-og-awz.headerTitle" = "Channel name"; /* Class = "UIBarButtonItem"; title = "Item"; ObjectID = "VkQ-fL-ON5"; */ "VkQ-fL-ON5.title" = "Item"; /* Class = "UINavigationItem"; title = "Create channel"; ObjectID = "Vna-Fa-6lB"; */ "Vna-Fa-6lB.title" = "Create channel"; /* Class = "UILabel"; text = "Invitation only"; ObjectID = "Xcj-Am-jtk"; */ "Xcj-Am-jtk.text" = "Invitation only"; ================================================ FILE: SiskinIM/localization/en.lproj/Main.strings ================================================ /* Class = "UILabel"; text = "by Tigase, Inc."; ObjectID = "8ba-yZ-XRA"; */ "8ba-yZ-XRA.text" = "by Tigase, Inc."; /* Class = "UITabBarItem"; title = "Chats"; ObjectID = "acW-dT-cKf"; */ "acW-dT-cKf.title" = "Chats"; /* Class = "UITextField"; placeholder = "Enter jid"; ObjectID = "BM3-28-huR"; */ "BM3-28-huR.placeholder" = "Enter jid"; /* Class = "UINavigationItem"; title = "Informations"; ObjectID = "bNp-S8-ulX"; */ "bNp-S8-ulX.title" = "Informations"; /* Class = "UINavigationItem"; title = "Attachments"; ObjectID = "C1j-4i-HDP"; */ "C1j-4i-HDP.title" = "Attachments"; /* Class = "UITabBarItem"; title = "Bookmarks"; ObjectID = "dgT-Yo-7nF"; */ "dgT-Yo-7nF.title" = "Bookmarks"; /* Class = "UILabel"; text = "Attachments"; ObjectID = "EpU-tc-DIx"; */ "EpU-tc-DIx.text" = "Attachments"; /* Class = "UILabel"; text = "Ask for presence updates"; ObjectID = "ffo-7n-Tn2"; */ "ffo-7n-Tn2.text" = "Ask for presence updates"; /* Class = "UILabel"; text = "Mute contact"; ObjectID = "g8N-kr-fax"; */ "g8N-kr-fax.text" = "Mute contact"; /* Class = "UITextField"; placeholder = "Select account"; ObjectID = "gXY-yq-Y2K"; */ "gXY-yq-Y2K.placeholder" = "Select account"; /* Class = "UITextField"; text = "local_user@example.com"; ObjectID = "gXY-yq-Y2K"; */ "gXY-yq-Y2K.text" = "local_user@example.com"; /* Class = "UITableViewSection"; headerTitle = "Name"; ObjectID = "Kfl-J5-hdD"; */ "Kfl-J5-hdD.headerTitle" = "Name"; /* Class = "UILabel"; text = "Block contact"; ObjectID = "kwQ-p7-cX2"; */ "kwQ-p7-cX2.text" = "Block contact"; /* Class = "UITableViewSection"; headerTitle = "Account"; ObjectID = "Mfv-HJ-QDO"; */ "Mfv-HJ-QDO.headerTitle" = "Account"; /* Class = "UICollectionViewController"; title = "Attachments"; ObjectID = "N9z-ms-iaT"; */ "N9z-ms-iaT.title" = "Attachments"; /* Class = "UINavigationController"; title = "Contacts"; ObjectID = "Ndx-if-NHK"; */ "Ndx-if-NHK.title" = "Contacts"; /* Class = "UITextField"; placeholder = "Enter display name"; ObjectID = "OGf-mX-8z3"; */ "OGf-mX-8z3.placeholder" = "Enter display name"; /* Class = "UITableViewSection"; headerTitle = "PRESENCE"; ObjectID = "Q28-Ig-9NP"; */ "Q28-Ig-9NP.headerTitle" = "PRESENCE"; /* Class = "UITableViewSection"; headerTitle = "XMPP Address (JID)"; ObjectID = "Qgj-eQ-geg"; */ "Qgj-eQ-geg.headerTitle" = "XMPP Address (JID)"; /* Class = "UITableViewController"; title = "Bookmarks"; ObjectID = "rba-pb-IiC"; */ "rba-pb-IiC.title" = "Bookmarks"; /* Class = "UILabel"; text = "Disclose my online status"; ObjectID = "upM-mW-rMZ"; */ "upM-mW-rMZ.text" = "Disclose my online status"; /* Class = "UITabBarItem"; title = "Contacts"; ObjectID = "W52-LN-wzX"; */ "W52-LN-wzX.title" = "Contacts"; /* Class = "UIButton"; normalTitle = "Create new XMPP account"; ObjectID = "WyQ-cb-VAl"; */ "WyQ-cb-VAl.normalTitle" = "Create new XMPP account"; /* Class = "UILabel"; text = "Message encryption"; ObjectID = "XZp-oZ-XpC"; */ "XZp-oZ-XpC.text" = "Message encryption"; /* Class = "UIButton"; normalTitle = "Sign in to an existing XMPP account"; ObjectID = "ZzM-t7-zvW"; */ "ZzM-t7-zvW.normalTitle" = "Sign in to an existing XMPP account"; ================================================ FILE: SiskinIM/localization/en.lproj/Settings.strings ================================================ /* Class = "UINavigationController"; title = "Settings"; ObjectID = "15C-WY-qrp"; */ "15C-WY-qrp.title" = "Settings"; /* Class = "UILabel"; text = "Chat markers & receipts"; ObjectID = "32B-bc-CHX"; */ "32B-bc-CHX.text" = "Chat markers & receipts"; /* Class = "UILabel"; text = "Show emoticons"; ObjectID = "3a8-1d-3PR"; */ "3a8-1d-3PR.text" = "Show emoticons"; /* Class = "UILabel"; text = "Notifications"; ObjectID = "3pd-ay-sAI"; */ "3pd-ay-sAI.text" = "Notifications"; /* Class = "UILabel"; text = "Notifications from unknown"; ObjectID = "67H-51-GcY"; */ "67H-51-GcY.text" = "Notifications from unknown"; /* Class = "UILabel"; text = "Media"; ObjectID = "8G3-tm-sCO"; */ "8G3-tm-sCO.text" = "Media"; /* Class = "UILabel"; text = "Enable markdown"; ObjectID = "8K5-bS-ddh"; */ "8K5-bS-ddh.text" = "Enable markdown"; /* Class = "UILabel"; text = "File download limit"; ObjectID = "8WB-sA-XVp"; */ "8WB-sA-XVp.text" = "File download limit"; /* Class = "UILabel"; text = "Get in touch"; ObjectID = "91g-qn-FOS"; */ "91g-qn-FOS.text" = "Get in touch"; /* Class = "UILabel"; text = "About the app"; ObjectID = "9cK-3e-Nqx"; */ "9cK-3e-Nqx.text" = "About the app"; /* Class = "UILabel"; text = "No blocked contacts"; ObjectID = "Aki-gv-qus"; */ "Aki-gv-qus.text" = "No blocked contacts"; /* Class = "UILabel"; text = "Photos quality"; ObjectID = "bbT-7s-6ph"; */ "bbT-7s-6ph.text" = "Photos quality"; /* Class = "UILabel"; text = "Send message on Return"; ObjectID = "Bd4-qf-OsU"; */ "Bd4-qf-OsU.text" = "Send message on Return"; /* Class = "UILabel"; text = "Videos quality"; ObjectID = "bKW-ln-Kez"; */ "bKW-ln-Kez.text" = "Videos quality"; /* Class = "UILabel"; text = "Use public STUN servers"; ObjectID = "BSa-RZ-M7L"; */ "BSa-RZ-M7L.text" = "Use public STUN servers"; /* Class = "UILabel"; text = "Contacts in groups"; ObjectID = "BSk-tI-BDM"; */ "BSk-tI-BDM.text" = "Contacts in groups"; /* Class = "UILabel"; text = "Push notifications"; ObjectID = "cyb-st-Bmw"; */ "cyb-st-Bmw.text" = "Push notifications"; /* Class = "UILabel"; text = "XMPP Quickstart / Pipelining"; ObjectID = "dbq-Tl-d6b"; */ "dbq-Tl-d6b.text" = "XMPP Quickstart / Pipelining"; /* Class = "UILabel"; text = "Chats"; ObjectID = "etv-3w-9lA"; */ "etv-3w-9lA.text" = "Chats"; /* Class = "UILabel"; text = "File sharing via HTTP"; ObjectID = "eZJ-fR-LPh"; */ "eZJ-fR-LPh.text" = "File sharing via HTTP"; /* Class = "UITableViewController"; title = "Experimental"; ObjectID = "FGQ-GL-dYt"; */ "FGQ-GL-dYt.title" = "Experimental"; /* Class = "UILabel"; text = "Appearance"; ObjectID = "fxY-aA-n0i"; */ "fxY-aA-n0i.text" = "Appearance"; /* Class = "UIBarButtonItem"; title = "Close"; ObjectID = "G2W-rB-KuE"; */ "G2W-rB-KuE.title" = "Close"; /* Class = "UILabel"; text = "2 lines"; ObjectID = "g4v-o5-nXZ"; */ "g4v-o5-nXZ.text" = "2 lines"; /* Class = "UITableViewController"; title = "Notification settings"; ObjectID = "GfS-6V-cuc"; */ "GfS-6V-cuc.title" = "Notification settings"; /* Class = "UILabel"; text = "Contacts"; ObjectID = "hGX-FI-YNj"; */ "hGX-FI-YNj.text" = "Contacts"; /* Class = "UILabel"; text = "Clear download cache"; ObjectID = "Hu1-2i-RSO"; */ "Hu1-2i-RSO.text" = "Clear download cache"; /* Class = "UILabel"; text = "Media"; ObjectID = "ieE-mK-uEm"; */ "ieE-mK-uEm.text" = "Media"; /* Class = "UINavigationItem"; title = "Settings"; ObjectID = "jeY-X9-tWB"; */ "jeY-X9-tWB.title" = "Settings"; /* Class = "UILabel"; text = "Auto-authorize contacts"; ObjectID = "MGl-L6-Fs3"; */ "MGl-L6-Fs3.text" = "Auto-authorize contacts"; /* Class = "UILabel"; text = "Blocked contacts"; ObjectID = "MJ0-kw-1Kl"; */ "MJ0-kw-1Kl.text" = "Blocked contacts"; /* Class = "UILabel"; text = "\"Hidden\" group"; ObjectID = "P82-B8-768"; */ "P82-B8-768.text" = "\"Hidden\" group"; /* Class = "UITableViewController"; title = "Chat settings"; ObjectID = "pFo-ox-4gT"; */ "pFo-ox-4gT.title" = "Chat settings"; /* Class = "UILabel"; text = "Groupchats bookmarks sync"; ObjectID = "Tbt-L3-jpa"; */ "Tbt-L3-jpa.text" = "Groupchats bookmarks sync"; /* Class = "UILabel"; text = "Icon"; ObjectID = "X1h-yo-uZt"; */ "X1h-yo-uZt.text" = "Icon"; /* Class = "UITableViewController"; title = "Contacts settings"; ObjectID = "xRS-v5-T5C"; */ "xRS-v5-T5C.title" = "Contacts settings"; /* Class = "UILabel"; text = "Sorting"; ObjectID = "Y0J-o4-ADK"; */ "Y0J-o4-ADK.text" = "Sorting"; /* Class = "UILabel"; text = "Show link previews"; ObjectID = "ybc-mt-hVG"; */ "ybc-mt-hVG.text" = "Show link previews"; /* Class = "UILabel"; text = "Experimental"; ObjectID = "YDd-q8-r7f"; */ "YDd-q8-r7f.text" = "Experimental"; /* Class = "UILabel"; text = "Clear link previews cache"; ObjectID = "Zff-Y8-Nz6"; */ "Zff-Y8-Nz6.text" = "Clear link previews cache"; /* Class = "UILabel"; text = "Encryption"; ObjectID = "ZnP-5r-iGO"; */ "ZnP-5r-iGO.text" = "Encryption"; /* Class = "UILabel"; text = "Message carbons"; ObjectID = "zwR-jn-5u6"; */ "zwR-jn-5u6.text" = "Message carbons"; ================================================ FILE: SiskinIM/localization/en.lproj/VoIP.strings ================================================ /* Class = "UITableViewSection"; footerTitle = "Select which account should be used to join channel"; ObjectID = "0d6-UW-iX3"; */ "0d6-UW-iX3.footerTitle" = "Select which account should be used to join channel"; /* Class = "UITableViewSection"; headerTitle = "Account"; ObjectID = "0d6-UW-iX3"; */ "0d6-UW-iX3.headerTitle" = "Account"; /* Class = "UITextField"; text = "test@example.com"; ObjectID = "9Dn-ee-8WK"; */ "9Dn-ee-8WK.text" = "test@example.com"; ================================================ FILE: SiskinIM/localization/es.lproj/Account.strings ================================================ /* Class = "UILabel"; text = "add email"; ObjectID = "06Y-Cg-0Q3"; */ "06Y-Cg-0Q3.text" = "añadir correo electrónico"; /* Class = "UILabel"; text = "Nickname"; ObjectID = "27D-rn-4zp"; */ "27D-rn-4zp.text" = "Alias"; /* Class = "UITextField"; placeholder = "Country"; ObjectID = "4hs-GL-9fd"; */ "4hs-GL-9fd.placeholder" = "País"; /* Class = "UILabel"; text = "Host"; ObjectID = "5fg-qS-fyV"; */ "5fg-qS-fyV.text" = "Alojamiento"; /* Class = "UILabel"; text = "Change account settings"; ObjectID = "70p-aF-3x5"; */ "70p-aF-3x5.text" = "Cambiar ajustes de cuenta"; /* Class = "UILabel"; text = "Scan QR Code to add me as a buddy"; ObjectID = "7cH-MH-cya"; */ "7cH-MH-cya.text" = "Escanear Código QR para añadirme como colega"; /* Class = "UITextField"; placeholder = "Required"; ObjectID = "7KN-S3-8XR"; */ "7KN-S3-8XR.placeholder" = "Requerido"; /* Class = "UILabel"; text = "From last"; ObjectID = "7td-ty-azW"; */ "7td-ty-azW.text" = "Desde el último"; /* Class = "UITableViewSection"; headerTitle = "Message Archiving"; ObjectID = "9PW-Rb-rop"; */ "9PW-Rb-rop.headerTitle" = "Archivado de mensajes"; /* Class = "UITableViewController"; title = "OMEMO fingerprints"; ObjectID = "A24-eF-tzh"; */ "A24-eF-tzh.title" = "Huellas OMEMO"; /* Class = "UILabel"; text = "When in Away/XA/DND state"; ObjectID = "aKW-CM-bl2"; */ "aKW-CM-bl2.text" = "Cuando esté Ausente/Ausencia extendida/No molestar"; /* Class = "UITextField"; placeholder = "Type"; ObjectID = "Clb-vT-t5A"; Note = "Placeholder text for selection of address/phone type (home/work)"; */ "Clb-vT-t5A.placeholder" = "Tipo"; /* Class = "UILabel"; text = "Port"; ObjectID = "clx-xZ-SVL"; */ "clx-xZ-SVL.text" = "Puerto"; /* Class = "UITextField"; placeholder = "City"; ObjectID = "Cmz-iN-c4d"; */ "Cmz-iN-c4d.placeholder" = "Ciudad"; /* Class = "UILabel"; text = "Use Direct TLS"; ObjectID = "dtA-fp-y9J"; */ "dtA-fp-y9J.text" = "Usar TLS directo"; /* Class = "UILabel"; text = "Month"; ObjectID = "E11-00-jW5"; */ "E11-00-jW5.text" = "Mes"; /* Class = "UINavigationItem"; title = "Settings"; ObjectID = "eIR-sl-fh5"; */ "eIR-sl-fh5.title" = "Ajustes"; /* Class = "UITableViewSection"; headerTitle = "Encryption"; ObjectID = "Et5-H6-t7C"; */ "Et5-H6-t7C.headerTitle" = "Cifrado"; /* Class = "UITextField"; placeholder = "Automatic"; ObjectID = "FBK-JI-crl"; */ "FBK-JI-crl.placeholder" = "Automático"; /* Class = "UILabel"; text = "add address"; ObjectID = "fRX-fN-yOj"; */ "fRX-fN-yOj.text" = "añadir dirección"; /* Class = "UILabel"; text = "Enabled"; ObjectID = "GTa-Ee-HQT"; */ "GTa-Ee-HQT.text" = "Habilitado"; /* Class = "UILabel"; text = "OMEMO fingerprint"; ObjectID = "gUr-GY-P3A"; */ "gUr-GY-P3A.text" = "Huella OMEMO"; /* Class = "UILabel"; text = "Delete"; ObjectID = "GWw-NK-6oU"; */ "GWw-NK-6oU.text" = "Borrar"; /* Class = "UINavigationItem"; title = "Account settings"; ObjectID = "gzC-dB-kIF"; */ "gzC-dB-kIF.title" = "Ajustes de cuenta"; /* Class = "UIBarButtonItem"; title = "Done"; ObjectID = "ijD-Kb-uyj"; */ "ijD-Kb-uyj.title" = "Hecho"; /* Class = "UILabel"; text = "add phone"; ObjectID = "jAE-vq-Vfj"; */ "jAE-vq-Vfj.text" = "añadir teléfono"; /* Class = "UITextField"; placeholder = "Street"; ObjectID = "KgL-GY-IFp"; */ "KgL-GY-IFp.placeholder" = "Calle"; /* Class = "UILabel"; text = "Advanced"; ObjectID = "KjA-5f-Mg4"; */ "KjA-5f-Mg4.text" = "Avanzado"; /* Class = "UITableViewSection"; headerTitle = "Password"; ObjectID = "LO4-Ys-cek"; */ "LO4-Ys-cek.headerTitle" = "Contraseña"; /* Class = "UIButton"; normalTitle = "Change avatar"; ObjectID = "Mo3-sc-7ss"; */ "Mo3-sc-7ss.normalTitle" = "Cambiar avatar"; /* Class = "UITextField"; placeholder = "Type"; ObjectID = "NK4-tE-QSu"; Note = "Placeholder text for selection of address/phone type (home/work)"; */ "NK4-tE-QSu.placeholder" = "Tipo"; /* Class = "UITextField"; placeholder = "Code"; ObjectID = "NUu-KT-QM5"; Note = "Postal Code"; */ "NUu-KT-QM5.placeholder" = "Código"; /* Class = "UILabel"; text = "Enable"; ObjectID = "P6B-h8-PVB"; */ "P6B-h8-PVB.text" = "Habilitar"; /* Class = "UITableViewSection"; footerTitle = "Receive push notifications when your other clients are connected but in Away/XA/DND state"; ObjectID = "PvC-LX-0Sp"; */ "PvC-LX-0Sp.footerTitle" = "Recibir notificaciones cuando tus otros clientes estén conectado pero en estado Ausente/Ausencia extendida/No molestar"; /* Class = "UITableViewSection"; headerTitle = "Push Notifications"; ObjectID = "PvC-LX-0Sp"; */ "PvC-LX-0Sp.headerTitle" = "Notificaciones Push"; /* Class = "UITableViewSection"; headerTitle = "Encryption"; ObjectID = "qGc-1C-qdh"; */ "qGc-1C-qdh.headerTitle" = "Cifrado"; /* Class = "UITextField"; placeholder = "Automatic"; ObjectID = "QX9-Nr-Eq6"; */ "QX9-Nr-Eq6.placeholder" = "Automático"; /* Class = "UIBarButtonItem"; title = "Next"; ObjectID = "R0m-k2-q64"; */ "R0m-k2-q64.title" = "Siguiente"; /* Class = "UIBarButtonItem"; title = "Skip"; ObjectID = "Rto-c0-DcC"; */ "Rto-c0-DcC.title" = "Saltar"; /* Class = "UITableViewSection"; footerTitle = "Enter your account JID"; ObjectID = "SKG-bP-NPK"; */ "SKG-bP-NPK.footerTitle" = "Entra tu cuenta JID"; /* Class = "UITableViewSection"; headerTitle = "XMPP ID"; ObjectID = "SKG-bP-NPK"; */ "SKG-bP-NPK.headerTitle" = "ID XMPP"; /* Class = "UITextField"; placeholder = "Phone number"; ObjectID = "T1r-DU-iqs"; */ "T1r-DU-iqs.placeholder" = "Número de teléfono"; /* Class = "UILabel"; text = "Server features"; ObjectID = "t3T-uh-mob"; */ "t3T-uh-mob.text" = "Características del servidor"; /* Class = "UITextField"; placeholder = "Type"; ObjectID = "UQ1-rc-tuY"; Note = "Placeholder text for selection of address/phone type (home/work)"; */ "UQ1-rc-tuY.placeholder" = "Tipo"; /* Class = "UITableViewSection"; headerTitle = "General"; ObjectID = "v8B-ee-zAk"; */ "v8B-ee-zAk.headerTitle" = "General"; /* Class = "UILabel"; text = "Enabled"; ObjectID = "wbI-5v-vug"; */ "wbI-5v-vug.text" = "Habilitado"; /* Class = "UITableViewSection"; headerTitle = "Connectivity"; ObjectID = "we8-hg-uOZ"; */ "we8-hg-uOZ.headerTitle" = "Conectividad"; /* Class = "UITableViewController"; title = "Server Features"; ObjectID = "wW7-3f-Kck"; */ "wW7-3f-Kck.title" = "Características del servidor"; /* Class = "UILabel"; text = "Title"; ObjectID = "XMr-e2-2o3"; */ "XMr-e2-2o3.text" = "Título"; /* Class = "UILabel"; text = "Disable TLS 1.3"; ObjectID = "xMW-iz-ghG"; */ "xMW-iz-ghG.text" = "Deshabilitar TLS 1.3"; /* Class = "UITextField"; placeholder = "Email address"; ObjectID = "ynp-RE-XlY"; */ "ynp-RE-XlY.placeholder" = "Dirección de correo electrónico"; ================================================ FILE: SiskinIM/localization/es.lproj/Conversation.strings ================================================ /* Class = "UIButton"; normalTitle = "Accept"; ObjectID = "7TV-Kq-bdP"; */ "7TV-Kq-bdP.normalTitle" = "Aceptar"; /* Class = "UILabel"; text = "Label"; ObjectID = "V6M-WP-Vfp"; */ "V6M-WP-Vfp.text" = "Etiqueta"; ================================================ FILE: SiskinIM/localization/es.lproj/Groupchat.strings ================================================ /* Class = "UILabel"; text = "Notifications"; ObjectID = "02f-aw-3Zd"; */ "02f-aw-3Zd.text" = "Notificaciones"; /* Class = "UITableViewSection"; footerTitle = "Push notification support depends on the XMPP server which you are using and may not work in some cases even if it's supported and enabled on the group chat"; ObjectID = "bRu-vo-fpu"; */ "bRu-vo-fpu.footerTitle" = "La compatibilidad con notificaciones push depende del servidor XMPP que estés utilizando y es posible que no funcione en algunos casos, incluso si es compatible y está habilitado en el grupo"; /* Class = "UITableViewSection"; headerTitle = "Settings"; ObjectID = "bRu-vo-fpu"; */ "bRu-vo-fpu.headerTitle" = "Ajustes"; /* Class = "UILabel"; text = "Encryption"; ObjectID = "DQk-sn-hKj"; */ "DQk-sn-hKj.text" = "Cifrado"; /* Class = "UILabel"; text = "When mentioned"; ObjectID = "exK-ZH-jpA"; */ "exK-ZH-jpA.text" = "Cuando sea mencionado"; /* Class = "UITableViewController"; title = "Room details"; ObjectID = "IGA-uE-mHb"; Note = "View with details of a room"; */ "IGA-uE-mHb.title" = "Detalles de la sala"; /* Class = "UITableViewSection"; headerTitle = "Subject"; ObjectID = "iYv-zL-tZT"; */ "iYv-zL-tZT.headerTitle" = "Asunto"; /* Class = "UILabel"; text = "Push notifications"; ObjectID = "jMz-qR-fJ6"; */ "jMz-qR-fJ6.text" = "Notificaciones push"; /* Class = "UITableViewController"; title = "Invite to chat"; ObjectID = "SOJ-3n-8YB"; */ "SOJ-3n-8YB.title" = "Invitar a chat"; /* Class = "UILabel"; text = "Attachments"; ObjectID = "wBw-1J-Xau"; */ "wBw-1J-Xau.text" = "Adjuntos"; ================================================ FILE: SiskinIM/localization/es.lproj/Info.strings ================================================ /* Class = "UITableViewSection"; headerTitle = "Application"; ObjectID = "1Kk-Xc-9Ce"; */ "1Kk-Xc-9Ce.headerTitle" = "Aplicación"; /* Class = "UILabel"; text = "Website"; ObjectID = "4wJ-8E-L1J"; */ "4wJ-8E-L1J.text" = "Sitio web"; /* Class = "UILabel"; text = "Website"; ObjectID = "56G-hc-AcV"; */ "56G-hc-AcV.text" = "Sitio web"; /* Class = "UITableViewController"; title = "Get in touch"; ObjectID = "5mK-5u-qus"; */ "5mK-5u-qus.title" = "Contactar"; /* Class = "UITableViewSection"; headerTitle = "Company"; ObjectID = "kdx-K5-gSV"; */ "kdx-K5-gSV.headerTitle" = "Compañía"; /* Class = "UILabel"; text = "Version: 5.0"; ObjectID = "lRd-ky-eXs"; */ "lRd-ky-eXs.text" = "Versión: 5.0"; /* Class = "UILabel"; text = "XMPP Channel"; ObjectID = "U4z-jz-alK"; */ "U4z-jz-alK.text" = "Canal XMPP"; ================================================ FILE: SiskinIM/localization/es.lproj/LaunchScreen.strings ================================================ ================================================ FILE: SiskinIM/localization/es.lproj/Localizable.strings ================================================ /* section label, device memory */ "%@ memory" = "%@ memoria"; /* no. of lines of messages preview label */ "%d lines of preview" = "%d líneas de previsualización"; /* conversation log groupchat direction label */ "(private message)" = "(mensaje privado)"; /* conversation log label */ "(this message has been removed)" = "(este mensaje ha sido eliminado)"; /* no. of lines of messages preview label */ "1 line of preview" = "1 línea de previsualización"; /* ssl certificate info - issue part */ "\nissued by\n%@\n with fingerprint\n%@" = "\nemitido por\n%1$@\n con huella\n%2$@"; /* button label */ "Accept" = "Aceptar"; /* channel join status view label */ "Account" = "Cuenta"; /* alert title */ "Account removal" = "Eliminación de cuenta"; /* section label */ "Accounts" = "Cuentas"; /* cell label */ "Add account" = "Añadir cuenta"; /* action label */ "Add contact" = "Añadir contacto"; /* button label */ "Add existing" = "Añadir existente"; /* contact details section vcard section label */ "Addresses" = "Direcciones"; /* filter scope */ "All" = "Todo"; /* alert message - unblock communication with server */ "All communication with users from %@ is blocked. Do you wish to unblock communication with this server?" = "Toda la comunicación con los usuarios de %@ está bloqueada. ¿Desea desbloquear la comunicación con este servidor?"; /* alert body */ "All messages will be deleted and all participants will be kicked out. Are you sure?" = "Se borrarán todos los mensajes y todos los participantes serán expulsados. ¿Estás seguro?"; /* conversation notifications status */ "Always" = "Siempre"; /* attachemt label for conversations list */ "Attachment" = "Adjunto"; /* section label */ "Attachments" = "Adjuntos"; /* action label */ "Audio call" = "Llamada de audio"; /* notification warning about authentication failure */ "Authentication for account %@ failed: %@" = "La autenticación para la cuenta %1$@ ha fallado: %2$@"; /* alert title body */ "Authentication for account %@ failed: %@\nVerify provided account password." = "La autenticación para la cuenta %1$@ ha fallado: %2$@\nVerifica la contraseña de la cuenta."; /* alert title */ "Authentication issue" = "Problema de autenticación"; /* appearance type */ "Auto" = "Auto"; /* audio output selection channel join status view label */ "Automatic" = "Automático"; /* filter scope */ "Available" = "Disponible"; /* presence status user status */ "Away" = "Ausente"; /* action label */ "Ban user" = "Prohibir usuario"; /* alert title */ "Banning user" = "Prohibición de usuario"; /* alert title */ "Banning user %@ failed" = "La prohibición al usuario %@ ha fallado"; /* user status */ "Be right back" = "Vuelvo enseguida"; /* alert body */ "Before changing roster you need to connect to server. Do you wish to do this now?" = "Antes de cambiar el roster necesitas conectar al servidor. ¿Quieres hacerlo ahora?"; /* vcard field label */ "Birthday" = "Fecha de nacimiento"; /* button label */ "Block" = "Bloquear"; /* button label */ "Block and report" = "Bloquear e informar"; /* context menu item */ "Block contact" = "Bloquear contacto"; /* action */ "Block participant" = "Bloquear participante"; /* context menu item */ "Block server" = "Bloquear servidor"; /* user status - contact blocked */ "Blocked" = "Obstruido"; /* channel participants view operation */ "Blocking…" = "Bloqueando…"; /* search bar scope */ "By name" = "Por nombre"; /* search bar scope */ "By status" = "Por estado"; /* call state label */ "Call ended" = "Llamada finalizada"; /* alert title */ "Call failed" = "Llamada fallada"; /* button label */ "Cancel" = "Cancelar"; /* alert title */ "Certificate issue" = "Emisión de certificado"; /* button label */ "Change" = "Cambiar"; /* button label */ "Change avatar" = "Cambiar avatar"; /* alert title button label */ "Change subject" = "Cambiar asunto"; /* alert title alert window title */ "Channel destruction failed!" = "¡Ha fallado la destrucción de canal!"; /* alert title */ "Channel destuction" = "Destrucción de canal"; /* action label presence status */ "Chat" = "Chat"; /* channel create view operation label channel join view operation label channel settings view opeartion label */ "Checking…" = "Comprobando…"; /* button label */ "Close" = "Cerrar"; /* channel join status view label */ "Component" = "Componente"; /* call state label */ "Connecting…" = "Conectando…"; /* error notification message */ "Connection to server %@ failed" = "Ha fallado la conexión al servidor %@"; /* attachment cell context action context action context action label */ "Copy" = "Copiar"; /* context action label */ "Correct…" = "Corregir…"; /* alert body */ "Could not create channel on the server. Got following error: %@" = "No se ha podido crear el canal en el servidor. Se ha obtenido el siguiente error: %@"; /* alert body */ "Could not delete account as it was not possible to connect to the XMPP server. Please try again later." = "No se pudo eliminar la cuenta porque no fue posible conectarse al servidor XMPP. Por favor, inténtalo de nuevo más tarde."; /* sharing error */ "Could not detect MIME type of a file." = "No se ha podido detectar el tipo MIME de un fichero."; /* alert title */ "Could not join" = "No se ha podido unir"; /* alert body */ "Could not join newly created channel '%@' on the server. Got following error: %@" = "No se pudo unir al canal recién creado '%@' en el servidor. Obtuve el siguiente error: %@"; /* alert body */ "Could not join room. Reason:\n%@" = "No se pudo unir a la sala. Razón:\n%@"; /* alert body */ "Could not rename group chat. The server responded with an error: %@" = "No se pudo renombra el grupo. El servidor respondió con un error: %@"; /* sharing error */ "Could not retrieve file size." = "No se pudo recuperar el tamaño de fichero."; /* alert body */ "Could not set group chat avatar. The server responded with an error: %@" = "No se pudo establecer el avatar del grupo. El servidor respondió con un error: %@"; /* alert title */ "Could not update channel details" = "No se pudieron actualizar los detalles del canal"; /* button label */ "Create" = "Crear"; /* button label */ "Create bookmark" = "Crear marcador"; /* label for chats list new converation action */ "Create meeting" = "Crear reunión"; /* button label */ "Create new" = "Crear nuevo"; /* cell sublabel */ "Create new or add existing account" = "Creada nueva cuenta o añadida un existente"; /* channel join view operation label */ "Creating channel…" = "Creando canal…"; /* appearance type */ "Dark" = "Oscuro"; /* encryption default label encryption setting value */ "Default" = "Por defecto"; /* default roster group */ "Default " = "Por defecto "; /* alert body */ "Default account is not connected. Please select a different account." = "La cuenta por defecto no está conectada. Por favor, selecciona otra."; /* action label attachment cell context action button label context action */ "Delete" = "Borrar"; /* alert title */ "Delete channel?" = "¿Borrar canal?"; /* button label */ "Destroy" = "Destruir"; /* alert title */ "Details" = "Detalles"; /* label for omemo device id */ "Device: %@" = "Dispositivo: %@"; /* button label */ "Disable autojoin" = "Deshabilitar autounión"; /* button label */ "Dismiss" = "Descartar"; /* section label */ "Display" = "Mostrar"; /* label for chat marker */ "Displayed" = "Mostrado"; /* presence status user status */ "Do not disturb" = "No molestar"; /* alert body */ "Do you want to ban user %@?" = "¿Quieres prohibir al usuario %@?"; /* alert body */ "Do you wish to publish this photo as avatar?" = "¿Deseas publicar esta foto como avatar?"; /* alert body */ "Do you wish to register a new account %@?" = "¿Deseas registrar la nueva cuenta %@?"; /* alert body */ "Do you wish to register a new account at %@?" = "¿Deseas registrar una nueva cuenta en %@?"; /* alert body */ "Do you wish to subscribe to \n%@\non account %@" = "¿Deseas suscribirte a\n%1$@\ncon la cuenta %2$@?"; /* attachment cell context action confirmation dialog title */ "Download" = "Descargar"; /* alert title */ "Download storage" = "Almacenamiento de descarga"; /* memory usage label */ "Downloads" = "Descargas"; /* action label */ "Edit" = "Editar"; /* contact details section vcard section label */ "Emails" = "Correos electrónicos"; /* button label */ "Enable" = "Habilitar"; /* button label */ "Enable autojoin" = "Habilitar autounir"; /* option description */ "Enabling message synchronization will enable message archiving on the server" = "Habilitar sincronización de mensajes habilitará el archivado de mensajes en el servidor"; /* contact details section */ "Encryption" = "Cifrado"; /* alert body */ "Enter default nickname to use in chats" = "Entrar alias por defecto para usar en chats"; /* alert body */ "Enter message to send to: %@" = "Entrar mensaje para enviar a: %@"; /* placeholder */ "Enter message…" = "Entrar mensaje…"; /* alert body */ "Enter new name for group chat" = "Entrar nuevo nombre para el grupo"; /* alert body */ "Enter new subject for group chat" = "Entrar nuevo asunto para el grupo"; /* alert body */ "Enter status message" = "Entrar mensaje de estado"; /* alert title */ "Error" = "Error"; /* error description message - detail */ "Error code" = "Código de error"; /* alert title */ "Error occurred" = "Ha ocurrido un error"; /* option to remove all data from local storage */ "Everything" = "Todo"; /* presence status */ "Extended away" = "Ausencia extendida"; /* alert title alert window title */ "Failure" = "Fallo"; /* vcard field label */ "Family name" = "Apellido"; /* sharing error */ "Feature not supported by XMPP server" = "Característica no soportada por el servidor XMPP"; /* file size label */ "File - %@" = "Fichero - %@"; /* confirmation dialog body */ "File is not available locally. Should it be downloaded?" = "El fichero no está disponible localmente. ¿Debería ser descargado?"; /* sharing error */ "File is too big to share" = "El fichero es demasiado grande para compartir"; /* section label */ "Fingerprint of this device" = "Huella de este dispositivo"; /* section label */ "For account" = "Para cuenta"; /* memory usage label */ "Free" = "Libre"; /* user status */ "Free for chat" = "Libre para conversar"; /* conversation log groupchat direction label */ "From" = "Desde"; /* conversation view input field placeholder */ "from %@…" = "desde %@…"; /* vcard field label */ "Full name" = "Nombre completo"; /* section label */ "General" = "General"; /* vcard field label */ "Given name" = "Nombre de pila"; /* video quality */ "High" = "Alta"; /* video quality */ "Highest" = "La más alta"; /* address type address type label */ "Home" = "Domicilio"; /* alert body push notifications option description */ "If enabled, you will receive notifications of new messages or calls even if SiskinIM is in background. SiskinIM servers will forward those notifications for you from XMPP servers." = "Si está habilitado, recibirás notificaciones de mensajes o llamadas incluso si SiskinIM en segundo plano. Los servidores SiskinIM redirigirán esas notificaciones para ti desde los servidores XMPP."; /* section footer */ "If you don't know any XMPP server domain names, then select one of our trusted servers." = "Si no conoces ningún nombre de servidor de dominio XMPP, selecciona uno de los servidores de confianza."; /* alert title */ "Incoming call" = "Llamada entrante"; /* alert body */ "Incoming call from %@" = "Llamada entrante desde %@"; /* action label */ "Info" = "Información"; /* section label */ "Initial synchronization" = "Sincronización inicial"; /* muc error reason */ "Invalid password" = "Contraseña no válida"; /* invitation label for chats list */ "Invitation" = "Invitación"; /* conversation log invitation to channel label */ "Invitation to channel %@" = "Invitación al canal %@"; /* muc invitation notification */ "Invitation to groupchat %@" = "Invitación al grupo %@"; /* button label */ "Invite" = "Invitar"; /* button label */ "Invite…" = "Invitar…"; /* error message */ "It was not possible to access camera or microphone. Please check privacy settings" = "No fue posible acceder a la cámara o el micrófono. Por favor, comprueba los ajustes de privacidad"; /* sharing error */ "It was not possible to access the file." = "No fue posible acceder al fichero."; /* push notifications registration failure message */ "It was not possible to contact push notification component." = "No fue posible contactar con el componente de notificaciones push."; /* push notifications registration failure message */ "It was not possible to contact push notification component.\nTry again later." = "No fue posible contactar con el componente de notificaciones push.\nInténtalo más tarde."; /* push notifications registration failure message */ "It was not possible to contact push notification component: %@" = "No fue posible contactar con el componente de notificación push: %@"; /* error message */ "It was not possible to contact XMPP server and sign in." = "No fue posible contactar con el servidor XMPP y entrar en él."; /* alert body */ "It was not possible to create a meeting. Server returned an error: %@" = "No fue posible crear una reunión. El servidor retornó un error: %@"; /* alert body alert window message */ "It was not possible to destroy channel %@. Server returned an error: %@" = "No fue posible destruir el canal %1$@. El servidor retornó un error: %2$@"; /* error message */ "It was not possible to establish call" = "No fue posible establecer llamada"; /* alert body */ "It was not possible to grant selected users access to the meeting. Received an error: %@" = "No fue posible otorgar acceso a la reunión a los usuarios seleccionados. Recibido error: %@"; /* alert body */ "It was not possible to initiate a call: %@" = "No fue posible iniciar una llamada: %@"; /* alert button */ "It was not possible to join a channel. The server returned an error: %@" = "No fue posible unirse al canal. El servidor devolvió un error: %@"; /* error description message */ "It was not possible to modify account." = "No fue posible modificar la cuenta."; /* alert title */ "It was not possible to save account details" = "No fue posible guardar los detalles de la cuenta"; /* alert body */ "It was not possible to save account details: %@" = "No fue posible guardar los detalles de la cuenta: %@"; /* alert title body */ "It was not possible to save account details: %@ Please try again later." = "No fue posible guardar los detalles de la cuenta: %@ Por favor, intentar más tarde."; /* unsent messages notification */ "It was not possible to send %d messages. Open the app to retry" = "No fue posible enviar %d mensajes. Abrir la aplicación para reintentar"; /* message encryption failure */ "It was not possible to send encrypted message due to encryption error" = "No fue posible enviar mensaje cifrado debido a un error de cifrado"; /* button label */ "Join" = "Unirse"; /* label for chats list new converation action */ "Join group chat" = "Unirse al grupo"; /* action label */ "Join room" = "Unirse a sala"; /* channel status label */ "Joined" = "Unido"; /* channel join view operation label muc room status */ "Joining…" = "Uniéndose…"; /* report spam action */ "Just block" = "Solo bloquea"; /* no OMEMO key - not generated yet */ "Key not generated!" = "¡Clave no generada!"; /* button label */ "Kick out" = "Expulsar"; /* option description */ "Large value may increase inital synchronization time" = "Valores más altos pueden incrementar el tiempo de sincronización inicial"; /* button label */ "Leave" = "Abandonar"; /* leaving channel title */ "Leaving channel" = "Abandonando canal"; /* appearance type */ "Light" = "Luminoso"; /* option description */ "Limits the size of the files sent to you which may be automatically downloaded" = "Limita el tamaño de los archivos que se te envían y que pueden descargarse automáticamente"; /* memory usage label */ "Link previews" = "Previsualizaciones de enlaces"; /* section label */ "List of messages" = "Lista de mensajes"; /* attachemt label for conversations list */ "Location" = "Ubicación"; /* error message */ "Login and password do not match." = "El usuario y la contraseña no coinciden."; /* video quality */ "Low" = "Baja"; /* muc error reason */ "Maximum number of users exceeded" = "Excedido el número máximo de usuarios"; /* me label for conversation log */ "Me" = "Yo"; /* video quality */ "Medium" = "Media"; /* alert title */ "Meeting ended" = "Reunión finalizada"; /* alert body */ "Meeting has ended" = "La reunión ha finalizado"; /* muc error reason */ "Membership is required to access the room" = "Se requiere membresía para acceder a la sala"; /* message decryption error message encryption failure */ "Message decryption failed! Error code: %d" = "¡Falló el descifrado de mensaje! Código de error: %d"; /* alert body */ "Message moderation failed!" = "¡La moderación del mensaje falló!"; /* section label */ "Message synchronization" = "Sincronización de mensaje"; /* message encryption failure */ "Message was not encrypted for this device" = "El mensaje no fue cifrado para este dispositivo"; /* message decryption error */ "Message was not encrypted for this device." = "El mensaje no fue cifrado para este dispositivo."; /* section label */ "Messages" = "Mensajes"; /* alert title */ "Metadata storage" = "Almacenamiento de metadatos"; /* alert title */ "Missed call" = "Llamada perdida"; /* alert body */ "Missed incoming call from %@" = "Perdida llamada entrante de %@"; /* context action label */ "Moderate" = "Moderado"; /* list of users with this role */ "Moderators" = "Moderadores"; /* synchronization period value */ "Month" = "Mes"; /* attachment cell context action context action label */ "More…" = "Más…"; /* conversation notifications status */ "Muted" = "Silenciado"; /* call state label */ "New call" = "Nueva llamada"; /* notification of incoming message on locked screen */ "New message" = "Nuevo mensaje"; /* new message without content notification */ "New message!" = "¡Nuevo mensaje!"; /* label for chats list new converation action */ "New private group chat" = "Nuevo grupo privado"; /* label for chats list new converation action */ "New public group chat" = "Nuevo grupo público"; /* alert title */ "Nickname" = "Alias"; /* muc error reason */ "Nickname already in use" = "Ya se está usándose el alias"; /* muc error reason */ "Nickname is locked down" = "El alias está bloqueado"; /* button label */ "No" = "No"; /* attachments view label */ "No attachments" = "Sin adjuntos"; /* channel join status view label encryption type encyption option list of users with this role */ "None" = "Nada"; /* channel status label muc room status label */ "Not connected" = "No conectado"; /* channel status label */ "Not joined" = "No unido"; /* action label */ "Nothing" = "Nada"; /* muc room status user status */ "Offline" = "Fuera de línea"; /* action label Button button label button lable */ "OK" = "OK"; /* option to remove all data from local storage option to remove data older than 7 days */ "Older than 7 days" = "Más antiguo de 7 días"; /* encryption option encryption type */ "OMEMO" = "OMEMO"; /* muc room status presence status user status */ "Online" = "En línea"; /* action label */ "Open chat" = "Abrir chat"; /* alert title */ "Open URL" = "Abrir URL"; /* vcard field label */ "Organization" = "Organización"; /* vcard field label */ "Organization role" = "Rol de la organización"; /* video quality */ "Original" = "Original"; /* selection warning */ "Original quality will share image in the format in which it is stored on your phone and it may not be supported by every device." = "La calidad original compartirá la imagen en el formato en el que está almacenada en tu teléfono y es posible que no sea compatible con todos los dispositivos."; /* selection warning */ "Original quality will share video in the format in which video is stored on your phone and it may not be supported by every device." = "La calidad original compartirá el video en el formato en el que se almacena el video en tu teléfono y es posible que no sea compatible con todos los dispositivos."; /* memory usage label */ "Other apps" = "Otras aplicaciones"; /* section label */ "Other devices fingerprints" = "Huellas de otros dispositivos"; /* list of users with this role */ "Participants" = "Participantes"; /* button label */ "Pass ownership" = "Titularidad del pase"; /* contact details section vcard section label */ "Phones" = "Teléfonos"; /* voice message state */ "Playing…" = "Reproduciendo…"; /* instruction to fill out the form */ "Please fill this form" = "Por favor, rellenar este formulario"; /* alert title */ "Please launch application from the home screen before continuing." = "Inicia la aplicación desde la pantalla de inicio antes de continuar."; /* sharing error */ "Please try again later." = "Por favor, intentar más tarde."; /* section label */ "Preferred domain name" = "Nombre de dominio preferido"; /* operation label */ "Preparing…" = "Preparando…"; /* attachment cell context action context action */ "Preview" = "Previsualización"; /* action label */ "Private message" = "Mensaje privado"; /* account registration error */ "Provided values are not acceptable" = "Los valores proporcionados no son aceptables"; /* alert title */ "Push notifications" = "Notificaciones push"; /* alert title */ "Push Notifications" = "Notificaciones Push"; /* alert body */ "Push notifications are enabled for %@. They need to be disabled before account can be removed and it is not possible to at this time. Please try again later." = "Las notificaciones push están habilitadas para %@. Deben desactivarse antes de que se pueda eliminar la cuenta y no es posible hacerlo en este momento. Por favor, inténtalo de nuevo más tarde."; /* push notifications registration failure message */ "Push notifications not available" = "Las notificaciones push no están disponibles"; /* section label */ "Quality of uploaded media" = "Calidad de los medios subidos"; /* synchronization period value */ "Quarter" = "Trimestral"; /* alert title */ "Question" = "Pregunta"; /* label for chat marker */ "Received" = "Recibido"; /* presence subscription request notification */ "Received presence subscription request from %@" = "Solicitud de suscripción de presencia recibida de %@"; /* alert title body */ "Received presence subscription request from\n%@\non account %@" = "Solicitud de suscripción de presencia recibida de\n%1$@\npara la cuenta %2$@"; /* voice message state */ "Recorded: %@" = "Grabado: %@"; /* voice message state */ "Recording…" = "Grabando…"; /* voice message state */ "Recording… %@" = "Grabando… %@"; /* channel block users view operation channel edit info operation channel participants view operation */ "Refreshing…" = "Refrescando…"; /* button label */ "Register" = "Registro"; /* alert title */ "Registering account" = "Registrando cuenta"; /* alert title */ "Registration failure" = "Fallo de registro"; /* account registration error */ "Registration is not supported by this server" = "Este servidor no soporta registro"; /* button label */ "Reject" = "Rechazar"; /* alert body */ "Remote server returned an error: %@" = "El servidor remoto devolvió un error: %@"; /* option description */ "Removal of cached attachments may lead to increased usage of network, if attachment may need to be redownloaded, or to lost files, if they are no longer available at the server." = "La eliminación de los adjuntos en caché puede llevar a un mayor uso de la red, si es necesario volver a descargar los archivos adjuntos, o a la pérdida de archivos, si ya no están disponibles en el servidor."; /* alert body */ "Remove account from application?" = "¿Borrar la cuenta de la aplicación?"; /* button label */ "Remove bookmark" = "Eliminar marcador"; /* button label */ "Remove from application" = "Eliminar de la aplicación"; /* button label */ "Remove from server" = "Eliminar del servidor"; /* button label */ "Rename" = "Renombrar"; /* alert title button label */ "Rename chat" = "Renombrar chat"; /* context action label */ "Reply…" = "Respuesta…"; /* context action label */ "Report & block…" = "Informar y bloquear…"; /* report abuse action */ "Report abuse" = "Informar de abuso"; /* report spam action */ "Report spam" = "Informar de spam"; /* button label */ "Resend" = "Reenviar"; /* context action label */ "Retract" = "Retirar"; /* call state label */ "Ringing…" = "Zumbido…"; /* alert title */ "Room %@" = "Sala %@"; /* muc error reason */ "Room is locked" = "La sala está cerrada"; /* alert body */ "Room was created and joined but room was not properly configured. Got following error: %@" = "La sala fue creada y unido pero no está apropiadamente configurada. Obtenido el siguiente error: %@"; /* search bar placeholder */ "Search channels" = "Buscar canales"; /* placeholder for location selection search bar */ "Search for places" = "Buscar lugares"; /* placeholder */ "Search to add…" = "Buscar para añadir…"; /* alert body */ "Select account to open chat from" = "Seleccionar cuenta desde la que abrir el chat"; /* selection information */ "Select appearance" = "Seleccionar aspecto"; /* selection application icon information */ "Select application icon" = "Seleccione el icono de la aplicación"; /* title for multiple contact selection */ "Select contacts" = "Seleccionar contactos"; /* selection information */ "Select default conversation encryption" = "Seleccionar el cifrado de conversación por defecto"; /* location selection window title */ "Select location" = "Seleccionar ubicación"; /* selection description */ "Select period of messages to be synchronized" = "Seleccionar el periodo de mensajes a sincronizar"; /* button label photo selection action */ "Select photo" = "Seleccionar foto"; /* media quality selection instruction */ "Select quality" = "Seleccionar calidad"; /* selection description */ "Select quality of the image to use for sharing" = "Seleccionar la calidad de imagen para compartir"; /* selection description */ "Select quality of the video to use for sharing" = "Seleccionar la calidad de vídeo para compartir"; /* view title */ "Select recipients" = "Seleccionar destinatarios"; /* alert title */ "Select status" = "Seleccionar estado"; /* button label */ "Send" = "Enviar"; /* alert title */ "Send message" = "Enviar mensaje"; /* channel invitations view operation */ "Sending invitations…" = "Enviando invitaciones…"; /* operation label */ "Sending…" = "Enviando…"; /* sharing error */ "Server did not confirm file upload correctly." = "El servidor no confirmó correctamente la carga del archivo."; /* ssl certificate alert dialog body */ "Server for domain %@ provided invalid certificate for %@\n with fingerprint\n%@%@.\nDo you trust this certificate?" = "El servidor del dominio %1$@ proporcionó un certificado no válido para %2$@\n con huella\n%3$@%4$@.\n¿Confías en este certificado?"; /* alert title - unblock communication with server */ "Server is blocked" = "El servidor esta bloqueado"; /* alert body */ "Server of selected account does not provide support for hosting meetings. Please select a different account." = "El servidor de la cuenta seleccionada no proporciona soporte para alojar reuniones. Por favor, seleccionar otra cuenta."; /* account registration error alert body */ "Server returned an error: %@" = "El servidor devolvió un error: %@"; /* account registration error */ "Service is not available at this time." = "El servicio no está disponible en este momento."; /* alert title */ "Service unavailable" = "Servicio no disponible"; /* button label */ "Set" = "Establecer"; /* contact details section section label */ "Settings" = "Ajustes"; /* attachment cell context action context action context action label */ "Share…" = "Compartir…"; /* section label */ "Sharing" = "Intercambio"; /* alert body */ "Sharing feature with HTTP upload is disabled within application. To use this feature you need to enable sharing with HTTP upload in application" = "La característica de compartir por carga HTTP está deshabilitada en la aplicación. Para usarla necesitas habilitar la compartición con carga HTPP en la aplicación"; /* alert body */ "Should account be removed from server as well?" = "¿Se debería eliminar también la cuenta del servidor?"; /* context action label */ "Show map" = "Mostrar mapa"; /* notifications from unknown description */ "Show notifications from people not in your contact list" = "Mostrar notificaciones de gente que no está en tu lista de contactos"; /* App icon */ "Simple" = "Sencillo"; /* audio output label */ "Speaker" = "Altavoz"; /* alert title */ "Start chatting" = "Empezar a chatear"; /* alert title section label */ "Status" = "Estado"; /* alert title */ "Subscribe to %@" = "Suscribir a %@"; /* alert title */ "Subscription request" = "Petición de suscripción"; /* button label */ "Switch audio" = "Cambiar audio"; /* button label */ "Switch camera" = "Cambiar cámara"; /* button label photo selection action */ "Take photo" = "Hacer foto"; /* report user message */ "The user %@ will be blocked. Should it be reported as well?" = "El usuario %@ será bloqueado. ¿Debería ser informado también?"; /* alert message */ "There is no service supporting channels for domain %@" = "No hay servicio de soporte de canales en el dominio %@"; /* message encryption failure */ "There is no trusted device to send message to" = "No hay dispositivo de confianza para enviar mensajes"; /* alert body */ "This room is not capable of sending encrypted messages. Please change encryption settings to be able to send messages" = "Esta sala no es capaz de enviar mensajes cifrados. Por favor, cambia los ajustes de cifrado para poder enviar mensajes"; /* conversation log groupchat direction label */ "To" = "A"; /* section label */ "Trusted servers" = "Servidores de confianza"; /* error recovery suggestion */ "Try again. If removal failed, try accessing Keychain to update account credentials manually." = "Intentar otra vez. Si la eliminación falla, intenta acceder a Keychain para actualizar las credenciales de la cuenta manualmente."; /* synchronization period value */ "Two weeks" = "Dos semanas"; /* action button label */ "Unblock" = "Desbloquear"; /* context menu action */ "Unblock server" = "Desbloquear servidor"; /* alert body */ "Unknown error occurred" = "Ha ocurrido un error desconocido"; /* unknown file label */ "Unknown file" = "Fichero desconocido"; /* allowed size of file to download */ "Unlimited" = "Sin límite"; /* conversation log label */ "Unread messages" = "Mensajes no leídos"; /* channel block users view operation channel edit info operation refresh conrol label */ "Updating…" = "Actualizando…"; /* alert title */ "Upload failed" = "Carga fallida"; /* sharing error */ "Upload to HTTP server failed." = "Carga en servidor HTTP fallida."; /* option description */ "Used image and video quality may impact storage and network usage" = "La calidad de imagen y vídeo puede impactar en el almacenamiento y el uso de red"; /* alert body */ "User avatar publication failed.\nReason: %@" = "Falló la publicación de avatar de usuario.\nRazón: %@"; /* muc error reason */ "User is banned" = "El usuario está prohibido"; /* account registration error */ "User with provided username already exists" = "El nombre de usuario ya existe"; /* account info label */ "using %@" = "usando %@"; /* alert body */ "VCard publication failed: %@" = "Falló la publicación de VCard: %@"; /* version of the app */ "Version: %@" = "Versión: %@"; /* action label */ "Video call" = "Llamada de vídeo"; /* list of users with this role */ "Visitors" = "Visitantes"; /* alert title */ "Warning" = "Advertencia"; /* alert body used space label */ "We are using %@ of storage." = "Estamos usando %@ de almacenamiento."; /* synchronization period value */ "Week" = "Semana"; /* alert body */ "What do you want to do with %@?" = "¿Qué quieres hacer con %@?"; /* conversation notifications status */ "When mentioned" = "Cuando sea mencionado"; /* alert body */ "When you share files using HTTP, they are uploaded to HTTP server with unique URL. Anyone who knows the unique URL to the file is able to download it.\nDo you wish to enable?" = "Cuando compartes archivos mediante HTTP, se cargan en el servidor HTTP con una URL única. Cualquiera que conozca la URL única del archivo puede descargarlo.\n¿Deseas habilitar?"; /* alert body */ "When you share files, they are uploaded to HTTP server with unique URL. Anyone who knows the unique URL to the file is able to download it.\nDo you wish to proceed?" = "Cuando compartes archivos mediante HTTP, se cargan en el servidor HTTP con una URL única. Cualquiera que conozca la URL única del archivo puede descargarlo.\n¿Deseas continuar?"; /* address type address type label */ "Work" = "Trabajo"; /* synchronization period value */ "Year" = "Año"; /* button label */ "Yes" = "Sí"; /* alert body */ "You are about to destroy channel %@. This will remove the channel on the server, remove remote history archive, and kick out all participants. Are you sure?" = "Estás a punto de destruir el canal %@. Esto eliminará el canal en el servidor, eliminará el archivo de historial remoto y expulsará a todos los participantes. ¿Estás seguro?"; /* error label */ "You are invied to join conversation at %@" = "Estás invitado a unirte a la conversación en %@"; /* alert body */ "You are not connected to room.\nPlease wait reconnection to room" = "No estás conectado a la sala.\nPor favor, reconecta con la sala"; /* alert body */ "You are not joined to the channel." = "No te has unido al canal."; /* leaving channel text */ "You are the last person with ownership of this channel. Please decide what to do with the channel." = "Eres la última persona con propiedad de este canal. Por favor, decide qué hacer con el canal."; /* push notifications not allowed warning */ "You need to allow application to show notifications and for background refresh." = "Debes permitir que la aplicación muestre notificaciones y se actualice en segundo plano."; /* alert body */ "You've left there room %@ and push notifications for this room were disabled!\nYou may need to reenable them on other devices." = "¡Has abandonado la sala %@ y las notificaciones push par esta sala han sido deshabilitadas!\nEs posible que debas volver a habilitarlas en otros dispositivos."; /* search location pin label */ "Your location" = "Tu ubicación"; ================================================ FILE: SiskinIM/localization/es.lproj/MIX.strings ================================================ /* Class = "UITextField"; placeholder = "Automatic"; ObjectID = "19A-3H-7QN"; */ "19A-3H-7QN.placeholder" = "Automático"; /* Class = "UILabel"; text = "Use MIX"; ObjectID = "4P1-zT-Par"; */ "4P1-zT-Par.text" = "Usar MIX"; /* Class = "UITableViewSection"; footerTitle = "Enter domain name of a component with channel or leave blank to automatically detect components with channels"; ObjectID = "94L-uC-ldZ"; */ "94L-uC-ldZ.footerTitle" = "Entrar el nombre de dominio de un componente con canal o déjar en blanco para detectar automáticamente componentes con canales"; /* Class = "UITableViewSection"; headerTitle = "Component domain"; ObjectID = "94L-uC-ldZ"; */ "94L-uC-ldZ.headerTitle" = "Dominio de componente"; /* Class = "UILabel"; text = "Create"; ObjectID = "9Ww-8v-Dml"; */ "9Ww-8v-Dml.text" = "Crear"; /* Class = "UITableViewSection"; headerTitle = "Experimental"; ObjectID = "aqR-Sm-2re"; */ "aqR-Sm-2re.headerTitle" = "Experimental"; /* Class = "UINavigationItem"; title = "Select channel"; ObjectID = "C5U-9C-poe"; */ "C5U-9C-poe.title" = "Seleccionar canal"; /* Class = "UITableViewSection"; headerTitle = "Bookmark"; ObjectID = "cOt-Oi-tab"; */ "cOt-Oi-tab.headerTitle" = "Marcador"; /* Class = "UIBarButtonItem"; title = "Item"; ObjectID = "cv8-VS-8c6"; */ "cv8-VS-8c6.title" = "Elemento"; /* Class = "UILabel"; text = "Autojoin"; ObjectID = "da6-M6-5nm"; */ "da6-M6-5nm.text" = "Autounirse"; /* Class = "UILabel"; text = "Notifications"; ObjectID = "fFa-pN-ndo"; */ "fFa-pN-ndo.text" = "Notificaciones"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "gNa-Cz-T88"; Note = "Placeholder for a field to notify user that it needs to be filled"; */ "gNa-Cz-T88.placeholder" = "requerido"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "Jiu-Fd-AsM"; */ "Jiu-Fd-AsM.placeholder" = "requerido"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "kD7-lz-IEK"; Note = "Placeholder for a field to notify user that it needs to be filled"; */ "kD7-lz-IEK.placeholder" = "requerido"; /* Class = "UITableViewController"; title = "Channel details"; ObjectID = "ke4-WK-unt"; */ "ke4-WK-unt.title" = "Detalles del canal"; /* Class = "UIBarButtonItem"; title = "Invite"; ObjectID = "Kre-sS-2vH"; */ "Kre-sS-2vH.title" = "Invitar"; /* Class = "UITextField"; placeholder = "Description"; ObjectID = "MsF-6z-TY3"; */ "MsF-6z-TY3.placeholder" = "Descripción"; /* Class = "UINavigationItem"; title = "Participants"; ObjectID = "mW8-st-X8N"; */ "mW8-st-X8N.title" = "Participantes"; /* Class = "UITableViewSection"; headerTitle = "Access"; ObjectID = "mXt-Xt-Bj5"; */ "mXt-Xt-Bj5.headerTitle" = "Acceder"; /* Class = "UILabel"; text = "Attachments"; ObjectID = "NWM-d4-jmq"; */ "NWM-d4-jmq.text" = "Adjuntos"; /* Class = "UITableViewSection"; headerTitle = "Nickname"; ObjectID = "NyO-PD-t9d"; */ "NyO-PD-t9d.headerTitle" = "Alias"; /* Class = "UILabel"; text = "Delete channel"; ObjectID = "O9e-sP-5IO"; */ "O9e-sP-5IO.text" = "Borrar canal"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "oIZ-xz-SzZ"; */ "oIZ-xz-SzZ.placeholder" = "requerido"; /* Class = "UIBarButtonItem"; title = "Join"; ObjectID = "Pgv-Uz-ZgP"; */ "Pgv-Uz-ZgP.title" = "Unirse"; /* Class = "UIBarButtonItem"; title = "Next"; ObjectID = "pZC-YZ-jlg"; */ "pZC-YZ-jlg.title" = "Siguiente"; /* Class = "UITableViewSection"; headerTitle = "Password"; ObjectID = "qB9-Eq-3RT"; */ "qB9-Eq-3RT.headerTitle" = "Contraseña"; /* Class = "UITextField"; placeholder = "Name"; ObjectID = "r60-FV-hoE"; */ "r60-FV-hoE.placeholder" = "Nombre"; /* Class = "UILabel"; text = "Change"; ObjectID = "Sso-1F-PI3"; */ "Sso-1F-PI3.text" = "Cambiar"; /* Class = "UITableViewSection"; footerTitle = "Select which account should be used to join channel"; ObjectID = "SUF-pg-qbn"; */ "SUF-pg-qbn.footerTitle" = "Seleccionar la cuenta que de debería usarse para unirse al canal"; /* Class = "UITableViewSection"; headerTitle = "Account"; ObjectID = "SUF-pg-qbn"; */ "SUF-pg-qbn.headerTitle" = "Cuenta"; /* Class = "UITableViewSection"; footerTitle = "Notification support and filtering depends on the XMPP server which you are using and may not work in some cases even if it's enabled here."; ObjectID = "Ta8-dG-cwn"; */ "Ta8-dG-cwn.footerTitle" = "EL soporte de notificación y filtrado depende del servidor XMPP que estés usando y podría no funcionar en algunos casos incluso estando habilitado aquí."; /* Class = "UITableViewSection"; headerTitle = "Settings"; ObjectID = "Ta8-dG-cwn"; */ "Ta8-dG-cwn.headerTitle" = "Ajustes"; /* Class = "UITableViewSection"; footerTitle = "ID of a channel used for joining (localpart of a JID)"; ObjectID = "u6K-qu-Pf5"; */ "u6K-qu-Pf5.footerTitle" = "ID del canal usado para unirse (parte local de un JID)"; /* Class = "UITableViewSection"; headerTitle = "Channel ID"; ObjectID = "u6K-qu-Pf5"; */ "u6K-qu-Pf5.headerTitle" = "ID de canal"; /* Class = "UINavigationItem"; title = "Blocked"; ObjectID = "uMJ-O9-BGV"; */ "uMJ-O9-BGV.title" = "Bloqueado"; /* Class = "UIBarButtonItem"; title = "Next"; ObjectID = "uX9-lh-BwU"; */ "uX9-lh-BwU.title" = "Siguiente"; /* Class = "UITableViewSection"; headerTitle = "Channel name"; ObjectID = "vcg-og-awz"; */ "vcg-og-awz.headerTitle" = "Nombre de canal"; /* Class = "UIBarButtonItem"; title = "Item"; ObjectID = "VkQ-fL-ON5"; */ "VkQ-fL-ON5.title" = "Elemento"; /* Class = "UINavigationItem"; title = "Create channel"; ObjectID = "Vna-Fa-6lB"; */ "Vna-Fa-6lB.title" = "Crear canal"; /* Class = "UILabel"; text = "Invitation only"; ObjectID = "Xcj-Am-jtk"; */ "Xcj-Am-jtk.text" = "Solo con invitación"; ================================================ FILE: SiskinIM/localization/es.lproj/Main.strings ================================================ /* Class = "UILabel"; text = "by Tigase, Inc."; ObjectID = "8ba-yZ-XRA"; */ "8ba-yZ-XRA.text" = "por Tigase, Inc."; /* Class = "UITabBarItem"; title = "Chats"; ObjectID = "acW-dT-cKf"; */ "acW-dT-cKf.title" = "Conversaciones"; /* Class = "UITextField"; placeholder = "Enter jid"; ObjectID = "BM3-28-huR"; */ "BM3-28-huR.placeholder" = "Entrar jid"; /* Class = "UINavigationItem"; title = "Informations"; ObjectID = "bNp-S8-ulX"; */ "bNp-S8-ulX.title" = "Informaciones"; /* Class = "UINavigationItem"; title = "Attachments"; ObjectID = "C1j-4i-HDP"; */ "C1j-4i-HDP.title" = "Adjuntos"; /* Class = "UITabBarItem"; title = "Bookmarks"; ObjectID = "dgT-Yo-7nF"; */ "dgT-Yo-7nF.title" = "Marcadores"; /* Class = "UILabel"; text = "Attachments"; ObjectID = "EpU-tc-DIx"; */ "EpU-tc-DIx.text" = "Adjuntos"; /* Class = "UILabel"; text = "Ask for presence updates"; ObjectID = "ffo-7n-Tn2"; */ "ffo-7n-Tn2.text" = "Pedir actualizaciones de presencia"; /* Class = "UILabel"; text = "Mute contact"; ObjectID = "g8N-kr-fax"; */ "g8N-kr-fax.text" = "Silenciar contacto"; /* Class = "UITextField"; placeholder = "Select account"; ObjectID = "gXY-yq-Y2K"; */ "gXY-yq-Y2K.placeholder" = "Seleccionar cuenta"; /* Class = "UITextField"; text = "local_user@example.com"; ObjectID = "gXY-yq-Y2K"; */ "gXY-yq-Y2K.text" = "usuario_local@ejemplo.com"; /* Class = "UITableViewSection"; headerTitle = "Name"; ObjectID = "Kfl-J5-hdD"; */ "Kfl-J5-hdD.headerTitle" = "Nombre"; /* Class = "UILabel"; text = "Block contact"; ObjectID = "kwQ-p7-cX2"; */ "kwQ-p7-cX2.text" = "Bloquear contacto"; /* Class = "UITableViewSection"; headerTitle = "Account"; ObjectID = "Mfv-HJ-QDO"; */ "Mfv-HJ-QDO.headerTitle" = "Cuenta"; /* Class = "UICollectionViewController"; title = "Attachments"; ObjectID = "N9z-ms-iaT"; */ "N9z-ms-iaT.title" = "Adjuntos"; /* Class = "UINavigationController"; title = "Contacts"; ObjectID = "Ndx-if-NHK"; */ "Ndx-if-NHK.title" = "Contactos"; /* Class = "UITextField"; placeholder = "Enter display name"; ObjectID = "OGf-mX-8z3"; */ "OGf-mX-8z3.placeholder" = "Entrar nombre para mostrar"; /* Class = "UITableViewSection"; headerTitle = "PRESENCE"; ObjectID = "Q28-Ig-9NP"; */ "Q28-Ig-9NP.headerTitle" = "PRESENCIA"; /* Class = "UITableViewSection"; headerTitle = "XMPP Address (JID)"; ObjectID = "Qgj-eQ-geg"; */ "Qgj-eQ-geg.headerTitle" = "Dirección XMPP (JID)"; /* Class = "UITableViewController"; title = "Bookmarks"; ObjectID = "rba-pb-IiC"; */ "rba-pb-IiC.title" = "Marcadores"; /* Class = "UILabel"; text = "Disclose my online status"; ObjectID = "upM-mW-rMZ"; */ "upM-mW-rMZ.text" = "Revelar mi estado en línea"; /* Class = "UITabBarItem"; title = "Contacts"; ObjectID = "W52-LN-wzX"; */ "W52-LN-wzX.title" = "Contactos"; /* Class = "UIButton"; normalTitle = "Create new XMPP account"; ObjectID = "WyQ-cb-VAl"; */ "WyQ-cb-VAl.normalTitle" = "Crear cuenta XMPP nueva"; /* Class = "UILabel"; text = "Message encryption"; ObjectID = "XZp-oZ-XpC"; */ "XZp-oZ-XpC.text" = "Cifrado de mensaje"; /* Class = "UIButton"; normalTitle = "Sign in to an existing XMPP account"; ObjectID = "ZzM-t7-zvW"; */ "ZzM-t7-zvW.normalTitle" = "Iniciar sesión con cuenta XMPP existente"; ================================================ FILE: SiskinIM/localization/es.lproj/Settings.strings ================================================ /* Class = "UINavigationController"; title = "Settings"; ObjectID = "15C-WY-qrp"; */ "15C-WY-qrp.title" = "Ajustes"; /* Class = "UILabel"; text = "Chat markers & receipts"; ObjectID = "32B-bc-CHX"; */ "32B-bc-CHX.text" = "Marcadores de chat y receptores"; /* Class = "UILabel"; text = "Show emoticons"; ObjectID = "3a8-1d-3PR"; */ "3a8-1d-3PR.text" = "Mostrar emoticonos"; /* Class = "UILabel"; text = "Notifications"; ObjectID = "3pd-ay-sAI"; */ "3pd-ay-sAI.text" = "Notificaciones"; /* Class = "UILabel"; text = "Notifications from unknown"; ObjectID = "67H-51-GcY"; */ "67H-51-GcY.text" = "Notificaciones de desconocidos"; /* Class = "UILabel"; text = "Media"; ObjectID = "8G3-tm-sCO"; */ "8G3-tm-sCO.text" = "Medio"; /* Class = "UILabel"; text = "Enable markdown"; ObjectID = "8K5-bS-ddh"; */ "8K5-bS-ddh.text" = "Habilitar marcado"; /* Class = "UILabel"; text = "File download limit"; ObjectID = "8WB-sA-XVp"; */ "8WB-sA-XVp.text" = "Límite de descarga de fichero"; /* Class = "UILabel"; text = "Get in touch"; ObjectID = "91g-qn-FOS"; */ "91g-qn-FOS.text" = "Contactar"; /* Class = "UILabel"; text = "About the app"; ObjectID = "9cK-3e-Nqx"; */ "9cK-3e-Nqx.text" = "Acerca de la aplicación"; /* Class = "UILabel"; text = "No blocked contacts"; ObjectID = "Aki-gv-qus"; */ "Aki-gv-qus.text" = "Contactos no bloqueados"; /* Class = "UILabel"; text = "Photos quality"; ObjectID = "bbT-7s-6ph"; */ "bbT-7s-6ph.text" = "Calidad de fotos"; /* Class = "UILabel"; text = "Send message on Return"; ObjectID = "Bd4-qf-OsU"; */ "Bd4-qf-OsU.text" = "Enviar mensaje de Retorno"; /* Class = "UILabel"; text = "Videos quality"; ObjectID = "bKW-ln-Kez"; */ "bKW-ln-Kez.text" = "Calidad de vídeos"; /* Class = "UILabel"; text = "Use public STUN servers"; ObjectID = "BSa-RZ-M7L"; */ "BSa-RZ-M7L.text" = "Usar servidores STUN públicos"; /* Class = "UILabel"; text = "Contacts in groups"; ObjectID = "BSk-tI-BDM"; */ "BSk-tI-BDM.text" = "Contactos en grupos"; /* Class = "UILabel"; text = "Push notifications"; ObjectID = "cyb-st-Bmw"; */ "cyb-st-Bmw.text" = "Notificaciones push"; /* Class = "UILabel"; text = "XMPP Quickstart / Pipelining"; ObjectID = "dbq-Tl-d6b"; */ "dbq-Tl-d6b.text" = "Arranque rápido de XMPP / Canalización"; /* Class = "UILabel"; text = "Chats"; ObjectID = "etv-3w-9lA"; */ "etv-3w-9lA.text" = "Conversaciones"; /* Class = "UILabel"; text = "File sharing via HTTP"; ObjectID = "eZJ-fR-LPh"; */ "eZJ-fR-LPh.text" = "Compartir ficheros por HTTP"; /* Class = "UITableViewController"; title = "Experimental"; ObjectID = "FGQ-GL-dYt"; */ "FGQ-GL-dYt.title" = "Experimental"; /* Class = "UILabel"; text = "Appearance"; ObjectID = "fxY-aA-n0i"; */ "fxY-aA-n0i.text" = "Aspecto"; /* Class = "UIBarButtonItem"; title = "Close"; ObjectID = "G2W-rB-KuE"; */ "G2W-rB-KuE.title" = "Cerrar"; /* Class = "UILabel"; text = "2 lines"; ObjectID = "g4v-o5-nXZ"; */ "g4v-o5-nXZ.text" = "2 líneas"; /* Class = "UITableViewController"; title = "Notification settings"; ObjectID = "GfS-6V-cuc"; */ "GfS-6V-cuc.title" = "Ajustes de notificación"; /* Class = "UILabel"; text = "Contacts"; ObjectID = "hGX-FI-YNj"; */ "hGX-FI-YNj.text" = "Contactos"; /* Class = "UILabel"; text = "Clear download cache"; ObjectID = "Hu1-2i-RSO"; */ "Hu1-2i-RSO.text" = "Limpiar caché de descargas"; /* Class = "UILabel"; text = "Media"; ObjectID = "ieE-mK-uEm"; */ "ieE-mK-uEm.text" = "Medio"; /* Class = "UINavigationItem"; title = "Settings"; ObjectID = "jeY-X9-tWB"; */ "jeY-X9-tWB.title" = "Ajustes"; /* Class = "UILabel"; text = "Auto-authorize contacts"; ObjectID = "MGl-L6-Fs3"; */ "MGl-L6-Fs3.text" = "Contactos auto-autorizados"; /* Class = "UILabel"; text = "Blocked contacts"; ObjectID = "MJ0-kw-1Kl"; */ "MJ0-kw-1Kl.text" = "Contactos bloqueados"; /* Class = "UILabel"; text = "\"Hidden\" group"; ObjectID = "P82-B8-768"; */ "P82-B8-768.text" = "Grupo \"oculto\""; /* Class = "UITableViewController"; title = "Chat settings"; ObjectID = "pFo-ox-4gT"; */ "pFo-ox-4gT.title" = "Ajustes de conversación"; /* Class = "UILabel"; text = "Groupchats bookmarks sync"; ObjectID = "Tbt-L3-jpa"; */ "Tbt-L3-jpa.text" = "Sincronización de marcadores de grupos"; /* Class = "UILabel"; text = "Icon"; ObjectID = "X1h-yo-uZt"; */ "X1h-yo-uZt.text" = "Icono"; /* Class = "UITableViewController"; title = "Contacts settings"; ObjectID = "xRS-v5-T5C"; */ "xRS-v5-T5C.title" = "Ajustes de contactos"; /* Class = "UILabel"; text = "Sorting"; ObjectID = "Y0J-o4-ADK"; */ "Y0J-o4-ADK.text" = "Ordenación"; /* Class = "UILabel"; text = "Show link previews"; ObjectID = "ybc-mt-hVG"; */ "ybc-mt-hVG.text" = "Mostrar previsualización de enlaces"; /* Class = "UILabel"; text = "Experimental"; ObjectID = "YDd-q8-r7f"; */ "YDd-q8-r7f.text" = "Experimental"; /* Class = "UILabel"; text = "Clear link previews cache"; ObjectID = "Zff-Y8-Nz6"; */ "Zff-Y8-Nz6.text" = "Limpiar caché de previsualización de enlaces"; /* Class = "UILabel"; text = "Encryption"; ObjectID = "ZnP-5r-iGO"; */ "ZnP-5r-iGO.text" = "Cifrado"; /* Class = "UILabel"; text = "Message carbons"; ObjectID = "zwR-jn-5u6"; */ "zwR-jn-5u6.text" = "Copias de mensajes"; ================================================ FILE: SiskinIM/localization/es.lproj/VoIP.strings ================================================ /* Class = "UITableViewSection"; footerTitle = "Select which account should be used to join channel"; ObjectID = "0d6-UW-iX3"; */ "0d6-UW-iX3.footerTitle" = "Seleccionar la cuenta que debería ser usada para unirse al canal"; /* Class = "UITableViewSection"; headerTitle = "Account"; ObjectID = "0d6-UW-iX3"; */ "0d6-UW-iX3.headerTitle" = "Cuenta"; /* Class = "UITextField"; text = "test@example.com"; ObjectID = "9Dn-ee-8WK"; */ "9Dn-ee-8WK.text" = "test@ejemplo.com"; ================================================ FILE: SiskinIM/localization/pl.lproj/Account.strings ================================================ /* Class = "UILabel"; text = "add email"; ObjectID = "06Y-Cg-0Q3"; */ "06Y-Cg-0Q3.text" = "dodaj email"; /* Class = "UILabel"; text = "Nickname"; ObjectID = "27D-rn-4zp"; */ "27D-rn-4zp.text" = "Nick"; /* Class = "UITextField"; placeholder = "Country"; ObjectID = "4hs-GL-9fd"; */ "4hs-GL-9fd.placeholder" = "Kraj"; /* Class = "UILabel"; text = "Host"; ObjectID = "5fg-qS-fyV"; */ "5fg-qS-fyV.text" = "Host"; /* Class = "UILabel"; text = "Change account settings"; ObjectID = "70p-aF-3x5"; */ "70p-aF-3x5.text" = "Zmień ustawienia konta"; /* Class = "UILabel"; text = "Scan QR Code to add me as a buddy"; ObjectID = "7cH-MH-cya"; */ "7cH-MH-cya.text" = "Zeskanuj kod QR Code aby dodać mnie jako znajomego"; /* Class = "UITextField"; placeholder = "Required"; ObjectID = "7KN-S3-8XR"; */ "7KN-S3-8XR.placeholder" = "Wymagane"; /* Class = "UILabel"; text = "From last"; ObjectID = "7td-ty-azW"; */ "7td-ty-azW.text" = "Z ostatniego"; /* Class = "UITableViewSection"; headerTitle = "Message Archiving"; ObjectID = "9PW-Rb-rop"; */ "9PW-Rb-rop.headerTitle" = "Archiwizacja widomości"; /* Class = "UITableViewController"; title = "OMEMO fingerprints"; ObjectID = "A24-eF-tzh"; */ "A24-eF-tzh.title" = "Odciski OMEMO"; /* Class = "UILabel"; text = "When in Away/XA/DND state"; ObjectID = "aKW-CM-bl2"; */ "aKW-CM-bl2.text" = "Gdy w stanie Away/XA/DND"; /* Class = "UITextField"; placeholder = "Type"; ObjectID = "Clb-vT-t5A"; Note = "Placeholder text for selection of address/phone type (home/work)"; */ "Clb-vT-t5A.placeholder" = "Typ"; /* Class = "UILabel"; text = "Port"; ObjectID = "clx-xZ-SVL"; */ "clx-xZ-SVL.text" = "Port"; /* Class = "UITextField"; placeholder = "City"; ObjectID = "Cmz-iN-c4d"; */ "Cmz-iN-c4d.placeholder" = "Miasto"; /* Class = "UILabel"; text = "Use Direct TLS"; ObjectID = "dtA-fp-y9J"; */ "dtA-fp-y9J.text" = "Użyj beżpośredniego TLS"; /* Class = "UILabel"; text = "Month"; ObjectID = "E11-00-jW5"; */ "E11-00-jW5.text" = "Miesiąc"; /* Class = "UINavigationItem"; title = "Settings"; ObjectID = "eIR-sl-fh5"; */ "eIR-sl-fh5.title" = "Ustawienia"; /* Class = "UITableViewSection"; headerTitle = "Encryption"; ObjectID = "Et5-H6-t7C"; */ "Et5-H6-t7C.headerTitle" = "Szyfrowanie"; /* Class = "UITextField"; placeholder = "Automatic"; ObjectID = "FBK-JI-crl"; */ "FBK-JI-crl.placeholder" = "Automatyczny"; /* Class = "UILabel"; text = "add address"; ObjectID = "fRX-fN-yOj"; */ "fRX-fN-yOj.text" = "dodaj adres"; /* Class = "UILabel"; text = "Enabled"; ObjectID = "GTa-Ee-HQT"; */ "GTa-Ee-HQT.text" = "Włączona"; /* Class = "UILabel"; text = "OMEMO fingerprint"; ObjectID = "gUr-GY-P3A"; */ "gUr-GY-P3A.text" = "Odcisk OMEMO"; /* Class = "UILabel"; text = "Delete"; ObjectID = "GWw-NK-6oU"; */ "GWw-NK-6oU.text" = "Usuń"; /* Class = "UINavigationItem"; title = "Account settings"; ObjectID = "gzC-dB-kIF"; */ "gzC-dB-kIF.title" = "Ustawienia konta"; /* Class = "UIBarButtonItem"; title = "Done"; ObjectID = "ijD-Kb-uyj"; */ "ijD-Kb-uyj.title" = "Gotowe"; /* Class = "UILabel"; text = "add phone"; ObjectID = "jAE-vq-Vfj"; */ "jAE-vq-Vfj.text" = "dodaj telefon"; /* Class = "UITextField"; placeholder = "Street"; ObjectID = "KgL-GY-IFp"; */ "KgL-GY-IFp.placeholder" = "Ulica"; /* Class = "UILabel"; text = "Advanced"; ObjectID = "KjA-5f-Mg4"; */ "KjA-5f-Mg4.text" = "Zaawansowane"; /* Class = "UITableViewSection"; headerTitle = "Password"; ObjectID = "LO4-Ys-cek"; */ "LO4-Ys-cek.headerTitle" = "Hasło"; /* Class = "UIButton"; normalTitle = "Change avatar"; ObjectID = "Mo3-sc-7ss"; */ "Mo3-sc-7ss.normalTitle" = "Zmień avatar"; /* Class = "UITextField"; placeholder = "Type"; ObjectID = "NK4-tE-QSu"; Note = "Placeholder text for selection of address/phone type (home/work)"; */ "NK4-tE-QSu.placeholder" = "Typ"; /* Class = "UITextField"; placeholder = "Code"; ObjectID = "NUu-KT-QM5"; Note = "Postal Code"; */ "NUu-KT-QM5.placeholder" = "Kod"; /* Class = "UILabel"; text = "Enable"; ObjectID = "P6B-h8-PVB"; */ "P6B-h8-PVB.text" = "Włącz"; /* Class = "UITableViewSection"; footerTitle = "Receive push notifications when your other clients are connected but in Away/XA/DND state"; ObjectID = "PvC-LX-0Sp"; */ "PvC-LX-0Sp.footerTitle" = "Otrzymuj powiadomienia push gdy inne klienty są połączone ale w stanie Away/XA/DND"; /* Class = "UITableViewSection"; headerTitle = "Push Notifications"; ObjectID = "PvC-LX-0Sp"; */ "PvC-LX-0Sp.headerTitle" = "Powiadomienia push"; /* Class = "UITableViewSection"; headerTitle = "Encryption"; ObjectID = "qGc-1C-qdh"; */ "qGc-1C-qdh.headerTitle" = "Szyfrowanie"; /* Class = "UITextField"; placeholder = "Automatic"; ObjectID = "QX9-Nr-Eq6"; */ "QX9-Nr-Eq6.placeholder" = "Automatycznie"; /* Class = "UIBarButtonItem"; title = "Next"; ObjectID = "R0m-k2-q64"; */ "R0m-k2-q64.title" = "Dalej"; /* Class = "UIBarButtonItem"; title = "Skip"; ObjectID = "Rto-c0-DcC"; */ "Rto-c0-DcC.title" = "Pomiń"; /* Class = "UITableViewSection"; footerTitle = "Enter your account JID"; ObjectID = "SKG-bP-NPK"; */ "SKG-bP-NPK.footerTitle" = "Wprowadź JID konta"; /* Class = "UITableViewSection"; headerTitle = "XMPP ID"; ObjectID = "SKG-bP-NPK"; */ "SKG-bP-NPK.headerTitle" = "XMPP ID"; /* Class = "UITextField"; placeholder = "Phone number"; ObjectID = "T1r-DU-iqs"; */ "T1r-DU-iqs.placeholder" = "Numer telefonu"; /* Class = "UILabel"; text = "Server features"; ObjectID = "t3T-uh-mob"; */ "t3T-uh-mob.text" = "Funkcje serwera"; /* Class = "UITextField"; placeholder = "Type"; ObjectID = "UQ1-rc-tuY"; Note = "Placeholder text for selection of address/phone type (home/work)"; */ "UQ1-rc-tuY.placeholder" = "Typ"; /* Class = "UITableViewSection"; headerTitle = "General"; ObjectID = "v8B-ee-zAk"; */ "v8B-ee-zAk.headerTitle" = "Ogólne"; /* Class = "UILabel"; text = "Enabled"; ObjectID = "wbI-5v-vug"; */ "wbI-5v-vug.text" = "Włączone"; /* Class = "UITableViewSection"; headerTitle = "Connectivity"; ObjectID = "we8-hg-uOZ"; */ "we8-hg-uOZ.headerTitle" = "Połączenie"; /* Class = "UITableViewController"; title = "Server Features"; ObjectID = "wW7-3f-Kck"; */ "wW7-3f-Kck.title" = "Funkcje Serwera"; /* Class = "UILabel"; text = "Title"; ObjectID = "XMr-e2-2o3"; */ "XMr-e2-2o3.text" = "Tytuł"; /* Class = "UILabel"; text = "Disable TLS 1.3"; ObjectID = "xMW-iz-ghG"; */ "xMW-iz-ghG.text" = "Wyłącz TLS 1.3"; /* Class = "UITextField"; placeholder = "Email address"; ObjectID = "ynp-RE-XlY"; */ "ynp-RE-XlY.placeholder" = "Adres e-mail"; ================================================ FILE: SiskinIM/localization/pl.lproj/Conversation.strings ================================================ /* Class = "UIButton"; normalTitle = "Accept"; ObjectID = "7TV-Kq-bdP"; */ "7TV-Kq-bdP.normalTitle" = "Akceptuj"; /* Class = "UILabel"; text = "Label"; ObjectID = "V6M-WP-Vfp"; */ "V6M-WP-Vfp.text" = "Label"; ================================================ FILE: SiskinIM/localization/pl.lproj/Groupchat.strings ================================================ /* Class = "UILabel"; text = "Notifications"; ObjectID = "02f-aw-3Zd"; */ "02f-aw-3Zd.text" = "Powiadomienia"; /* Class = "UITableViewSection"; footerTitle = "Push notification support depends on the XMPP server which you are using and may not work in some cases even if it's supported and enabled on the group chat"; ObjectID = "bRu-vo-fpu"; */ "bRu-vo-fpu.footerTitle" = "Wsparcie dla powiadomień push zależy od serwera XMPP którego używasz i może nie działać mimo, iż jest włączone dla tego kanału"; /* Class = "UITableViewSection"; headerTitle = "Settings"; ObjectID = "bRu-vo-fpu"; */ "bRu-vo-fpu.headerTitle" = "Ustawienia"; /* Class = "UILabel"; text = "Encryption"; ObjectID = "DQk-sn-hKj"; */ "DQk-sn-hKj.text" = "Szyfrowanie"; /* Class = "UILabel"; text = "When mentioned"; ObjectID = "exK-ZH-jpA"; */ "exK-ZH-jpA.text" = "Gdy wspomniano"; /* Class = "UITableViewController"; title = "Room details"; ObjectID = "IGA-uE-mHb"; Note = "View with details of a room"; */ "IGA-uE-mHb.title" = "Szczegóły pokoju"; /* Class = "UITableViewSection"; headerTitle = "Subject"; ObjectID = "iYv-zL-tZT"; */ "iYv-zL-tZT.headerTitle" = "Temat"; /* Class = "UILabel"; text = "Push notifications"; ObjectID = "jMz-qR-fJ6"; */ "jMz-qR-fJ6.text" = "Powiadomienia push"; /* Class = "UITableViewController"; title = "Invite to chat"; ObjectID = "SOJ-3n-8YB"; */ "SOJ-3n-8YB.title" = "Zaproś do kanału"; /* Class = "UILabel"; text = "Attachments"; ObjectID = "wBw-1J-Xau"; */ "wBw-1J-Xau.text" = "Załączniki"; ================================================ FILE: SiskinIM/localization/pl.lproj/Info.strings ================================================ /* Class = "UITableViewSection"; headerTitle = "Application"; ObjectID = "1Kk-Xc-9Ce"; */ "1Kk-Xc-9Ce.headerTitle" = "Aplikacja"; /* Class = "UILabel"; text = "Website"; ObjectID = "4wJ-8E-L1J"; */ "4wJ-8E-L1J.text" = "Strona internetowa"; /* Class = "UILabel"; text = "Website"; ObjectID = "56G-hc-AcV"; */ "56G-hc-AcV.text" = "Strona internetowa"; /* Class = "UITableViewController"; title = "Get in touch"; ObjectID = "5mK-5u-qus"; */ "5mK-5u-qus.title" = "Kontakt"; /* Class = "UITableViewSection"; headerTitle = "Company"; ObjectID = "kdx-K5-gSV"; */ "kdx-K5-gSV.headerTitle" = "Firma"; /* Class = "UILabel"; text = "Version: 5.0"; ObjectID = "lRd-ky-eXs"; */ "lRd-ky-eXs.text" = "Wersja: 5.0"; /* Class = "UILabel"; text = "XMPP Channel"; ObjectID = "U4z-jz-alK"; */ "U4z-jz-alK.text" = "Kanał XMPP"; ================================================ FILE: SiskinIM/localization/pl.lproj/LaunchScreen.strings ================================================ ================================================ FILE: SiskinIM/localization/pl.lproj/Localizable.strings ================================================ /* section label, device memory */ "%@ memory" = "%@ - pamięć"; /* no. of lines of messages preview label */ "%d lines of preview" = "%d linie podglądu"; /* conversation log groupchat direction label */ "(private message)" = "(wiadomość prywatna)"; /* conversation log label */ "(this message has been removed)" = "(wiadomość została usunięta)"; /* no. of lines of messages preview label */ "1 line of preview" = "1 linia podglądu"; /* ssl certificate info - issue part */ "\nissued by\n%@\n with fingerprint\n%@" = "\nwystawiony przez\n%@\nz odciskiem\n%@"; /* button label */ "Accept" = "Akceptuj"; /* channel join status view label */ "Account" = "Konto"; /* alert title */ "Account removal" = "Usuwanie konta"; /* section label */ "Accounts" = "Konta"; /* cell label */ "Add account" = "Dodaj konto"; /* action label */ "Add contact" = "Dodaj kontakt"; /* button label */ "Add existing" = "Dodaj istniejcące"; /* contact details section vcard section label */ "Addresses" = "Adresy"; /* filter scope */ "All" = "Wszyscy"; /* alert message - unblock communication with domain */ "All communication with users from %@ is blocked. Do you wish to unblock communication with this server?" = "Cała komunikacja z użytkownikami serwera %@ jest zablokowana. Czy chcesz odblokować komunikację z tym serwerem?"; /* alert body */ "All messages will be deleted and all participants will be kicked out. Are you sure?" = "Wszystkie wiadomości zostaną usunięte a uczestnicy zostaną wyrzuceni. Czy jesteś pewien?"; /* conversation notifications status */ "Always" = "Zawsze"; /* attachemt label for conversations list */ "Attachment" = "Załącznik"; /* section label */ "Attachments" = "Załączniki"; /* action label */ "Audio call" = "Rozmowa audio"; /* notification warning about authentication failure */ "Authentication for account %@ failed: %@" = "Uwierzytelnianie dla konta %@ nie powiodła się: %@"; /* alert title body */ "Authentication for account %@ failed: %@\nVerify provided account password." = "Uwierzytelnianie dla konta %@ nie powiodło się: %@\nSprawdź hasło do konta."; /* alert title */ "Authentication issue" = "Problem uwierzytelniania"; /* appearance type */ "Auto" = "Auto"; /* channel join status view label */ "Automatic" = "Automatycznie"; /* filter scope */ "Available" = "Dostępni"; /* presence status user status */ "Away" = "Zaraz wracam"; /* action label */ "Ban user" = "Zablokuj użytkownika"; /* alert title */ "Banning user" = "Blokowanie użytkownika"; /* alert title */ "Banning user %@ failed" = "Blokowanie użytkownika %@ nie udało się"; /* user status */ "Be right back" = "Zaraz wracam"; /* alert body */ "Before changing roster you need to connect to server. Do you wish to do this now?" = "Przed modyfikację listy kontaktów musisz połączyć się z serwerem. Czy zrobić to teraz?"; /* vcard field label */ "Birthday" = "Urodziny"; /* button label */ "Block" = "Zablokuj"; /* button label */ "Block and report" = "Zablokuj i zgłoś"; /* context menu item */ "Block contact" = "Zablokuj kontakt"; /* action */ "Block participant" = "Zablokuj uczestnika"; /* context menu item */ "Block server" = "Zablokuj server"; /* user status - contact blocked */ "Blocked" = "Zablokowany"; /* channel participants view operation */ "Blocking…" = "Blokowanie…"; /* search bar scope */ "By name" = "Po nazwie"; /* search bar scope */ "By status" = "Po statusie"; /* call state label */ "Call ended" = "Rozmowa zakończona"; /* alert title */ "Call failed" = "Połącznie nieudane"; /* button label */ "Cancel" = "Anuluj"; /* alert title */ "Certificate issue" = "Problem z certyfikatem"; /* button label */ "Change" = "Zmień"; /* button label */ "Change avatar" = "Zmień avatar"; /* alert title button label */ "Change subject" = "Zmień temat"; /* alert title */ "Channel destruction failed!" = "Niszczenie kanału nie powiodło się!"; /* alert title */ "Channel destuction" = "Usuwanie kanału"; /* action label presence status */ "Chat" = "Chętny do rozmowy"; /* channel create view operation label channel join view operation label channel settings view opeartion label */ "Checking…" = "Sprawdzanie…"; /* button label */ "Close" = "Zamknij"; /* channel join status view label */ "Component" = "Komponent"; /* call state label */ "Connecting…" = "Łączenie…"; /* error notification message */ "Connection to server %@ failed" = "Połączenie z serwerem %@ nie udało się"; /* attachment cell context action */ "Copy" = "Kopiuj"; /* context action label */ "Correct…" = "Popraw…"; /* alert body */ "Could not create channel on the server. Got following error: %@" = "Nie udało sie utworzy kanału na serwerze. Serwer zwrócił błąd: %@"; /* alert body */ "Could not delete account as it was not possible to connect to the XMPP server. Please try again later." = "Nie udało się usunąć konta, gdyż nie udało się połączyć z serwerem XMPP. Proszę spróbować później."; /* sharing error */ "Could not detect MIME type of a file." = "Nie udało się wykryć typu pliku."; /* alert title */ "Could not join" = "Nie udało się dołączyć"; /* alert body */ "Could not join newly created channel '%@' on the server. Got following error: %@" = "Nie udało się dołączyć do nowoutworzonego kanału '%@' na serwerze. Serwer zwrócił błąd: %@"; /* alert body */ "Could not join room. Reason:\n%@" = "Nie udało się dołączyć do pokoju. Powód:\n%@"; /* alert body */ "Could not rename group chat. The server responded with an error: %@" = "Nie udało się zmienić nazwy pokoju. Serwer zwrócił błąd: %@"; /* sharing error */ "Could not retrieve file size." = "Nie udało się pobrać wielkości pliku."; /* alert body */ "Could not set group chat avatar. The server responded with an error: %@" = "Nie udało się ustawić avatara dla pokoju. Serwer zwrócił błąd: %@"; /* alert title */ "Could not update channel details" = "Nie udało się zaktualizować informacji o kanale"; /* button label */ "Create" = "Utwórz"; /* button label */ "Create bookmark" = "Utwórz zakładkę"; /* label for chats list new converation action */ "Create meeting" = "Utwórz spotkanie"; /* button label */ "Create new" = "Stwórz nowe"; /* cell sublabel */ "Create new or add existing account" = "Stwórz nowe lub dodaj instniejące konto"; /* channel join view operation label */ "Creating channel…" = "Tworzenie kanału…"; /* appearance type */ "Dark" = "Ciemny"; /* encryption default label encryption setting value */ "Default" = "Domyślne"; /* default roster group */ "Default " = "Domyślna"; /* alert body */ "Default account is not connected. Please select a different account." = "Domyślne konto nie jest połączone. Proszę wybrać inne konto."; /* attachment cell context action */ "Delete" = "Usuń"; /* alert title */ "Delete channel?" = "Usunąć kanał?"; /* button label */ "Destroy" = "Zniszcz"; /* alert title */ "Details" = "Szczegóły"; /* label for omemo device id */ "Device: %@" = "Urządzenie: %@"; /* button label */ "Disable autojoin" = "Nie dołączaj automatycznie"; /* button label */ "Dismiss" = "Odrzuć"; /* section label */ "Display" = "Wyświetlanie"; /* label for chat marker */ "Displayed" = "Wyświetlona"; /* presence status user status */ "Do not disturb" = "Nie przeszkadzać"; /* alert body */ "Do you want to ban user %@?" = "Czy chcesz zablokować użytkownika %@?"; /* alert body */ "Do you wish to publish this photo as avatar?" = "Czy chcesz opublikować to zdjęcie jako avatar?"; /* alert body */ "Do you wish to register a new account %@?" = "Czy chcesz zarejestrować nowego konto %@?"; /* alert body */ "Do you wish to register a new account at %@?" = "Czy chcesz zarejestrować nowe konto w domenie %@?"; /* alert body */ "Do you wish to subscribe to \n%@\non account %@" = "Czy chcesz subskrybować \n%@\nz konta %@"; /* attachment cell context action confirmation dialog title */ "Download" = "Pobierz"; /* alert title */ "Download storage" = "Pobrane dane"; /* memory usage label */ "Downloads" = "Pobrane dane"; /* action label */ "Edit" = "Edytuj"; /* contact details section vcard section label */ "Emails" = "Emaile"; /* button label */ "Enable" = "Włączone"; /* button label */ "Enable autojoin" = "Dołączaj automatycznie"; /* option description */ "Enabling message synchronization will enable message archiving on the server" = "Włączenie synchronizacji wiadomości włączy archiwizację wiadomości na serwerze"; /* contact details section */ "Encryption" = "Szyfrowanie"; /* alert body */ "Enter default nickname to use in chats" = "Wprowadź domyślny nick używany w czatach"; /* alert body */ "Enter message to send to: %@" = "Wprowadź wiadomość by wysłać do: %@"; /* placeholder */ "Enter message…" = "Wprowadź wiadomość…"; /* alert body */ "Enter new name for group chat" = "Wprowadź nową nazwę czatu"; /* alert body */ "Enter new subject for group chat" = "Wprowadź nowy temat czatu"; /* alert body */ "Enter status message" = "Wprowadź status"; /* alert title */ "Error" = "Błąd"; /* error description message - detail */ "Error code" = "Kod błędu"; /* alert title */ "Error occurred" = "Wystąpił błąd"; /* option to remove all data from local storage */ "Everything" = "Wszystko"; /* presence status */ "Extended away" = "Niedostępny"; /* alert title */ "Failure" = "Niepowodzenie"; /* vcard field label */ "Family name" = "Nazwisko"; /* sharing error */ "Feature not supported by XMPP server" = "Funkcja nie jest dostępna na serwerze XMPP"; /* file size label */ "File - %@" = "Plik - %@"; /* confirmation dialog body */ "File is not available locally. Should it be downloaded?" = "Plik nie jest dostępny lokalnie. Czy pobrać?"; /* sharing error */ "File is too big to share" = "Plik jest za duży by go udostępnić"; /* section label */ "Fingerprint of this device" = "Odcisk tego urządzenia"; /* section label */ "For account" = "Dla konta"; /* memory usage label */ "Free" = "Wolne"; /* user status */ "Free for chat" = "Chętny do rozmowy"; /* conversation log groupchat direction label */ "From" = "Od"; /* conversation view input field placeholder */ "from %@…" = "używając %@…"; /* vcard field label */ "Full name" = "Pełna nazwa"; /* section label */ "General" = "Ogólne"; /* vcard field label */ "Given name" = "Imię"; /* video quality */ "High" = "Wysoka"; /* video quality */ "Highest" = "Najwyższa"; /* address type address type label */ "Home" = "Dom"; /* alert body push notifications option description */ "If enabled, you will receive notifications of new messages or calls even if SiskinIM is in background. SiskinIM servers will forward those notifications for you from XMPP servers." = "Gdy włączone, będziesz otrzymywał powiadomienia o nowych wiadomościach i połączeniach nawet gdy SiskinIM będzie w tle. Serwery SiskinIM przekażą te informacje dla Ciebie z serwerów XMPP."; /* section footer */ "If you don't know any XMPP server domain names, then select one of our trusted servers." = "Jeśli nie znasz żadnej domeny z XMPP, wybierz jednen naszych zaufanych serwerów."; /* alert title */ "Incoming call" = "Przychodząca rozmowa"; /* alert body */ "Incoming call from %@" = "Przychodząca rozmowa od %@"; /* action label */ "Info" = "Informacje"; /* section label */ "Initial synchronization" = "Pierwsza synchronizacja"; /* muc error reason */ "Invalid password" = "Niepoprawne hasło"; /* invitation label for chats list */ "Invitation" = "Zaproszenie"; /* conversation log invitation to channel label */ "Invitation to channel %@" = "Zaproszenie do kanału %@"; /* muc invitation notification */ "Invitation to groupchat %@" = "Zaproszenie do pokoju %@"; /* button label */ "Invite" = "Zaproś"; /* button label */ "Invite…" = "Zaproś…"; /* error message */ "It was not possible to access camera or microphone. Please check privacy settings" = "Nie udało się uzyskać dostępu do kamery lub mikrofonu. Sprawdź ustawienia prywatności"; /* sharing error */ "It was not possible to access the file." = "Nie udało się uzyskać dostępu do pliku."; /* push notifications registration failure message */ "It was not possible to contact push notification component." = "Nie udało się skontaktować z komponentem powiadomień push."; /* push notifications registration failure message */ "It was not possible to contact push notification component.\nTry again later." = "Nie udało się skontaktować z komponentem powiadomień push.\nSpróbuj ponownie później."; /* push notifications registration failure message */ "It was not possible to contact push notification component: %@" = "Nie udało się skontaktować z komponentem powiadomień push: %@"; /* error message */ "It was not possible to contact XMPP server and sign in." = "Nie udało się połączyć i zalogować do serwera XMPP."; /* alert body */ "It was not possible to create a meeting. Server returned an error: %@" = "Nie udało się utworzyć spotkania. Serwer zwrócił błąd: %@"; /* alert body */ "It was not possible to destroy channel %@. Server returned an error: %@" = "Nie udało się zniszczyć kanału %@. Serwer zwrócił błąd: %@"; /* error message */ "It was not possible to establish call" = "Nie udało się zrealizować połącznia"; /* alert body */ "It was not possible to grant selected users access to the meeting. Received an error: %@" = "Nie udało się pozwolić na dostęp do spotkania. Wystąpił błąd: %@"; /* alert body */ "It was not possible to initiate a call: %@" = "Nie udało się zainicjować połączenia: %@"; /* alert button */ "It was not possible to join a channel. The server returned an error: %@" = "Nie udało się dołączyć do kanału. Serwer zwrócił błąd: %@"; /* error description message */ "It was not possible to modify account." = "Nie udało się zmodyfikować konta."; /* alert title */ "It was not possible to save account details" = "Nie udalo się zapisać ustawień konta"; /* alert body */ "It was not possible to save account details: %@" = "Nie udalo się zapisać ustawień konta: %@"; /* alert title body */ "It was not possible to save account details: %@ Please try again later." = "Nie udało się zapisać szczegółów konta: %@ Proszę spróbować później."; /* unsent messages notification */ "It was not possible to send %d messages. Open the app to retry" = "Nie udało sie wysłać %d wiadomości. Otwórz aplikację aby spróbować ponownie"; /* message encryption failure */ "It was not possible to send encrypted message due to encryption error" = "Nie udało się wysłać zaszyfrowanej wiadomości z uwagi na błąd szyfrowania"; /* button label */ "Join" = "Dołącz"; /* label for chats list new converation action */ "Join group chat" = "Dołącz do czatu grupowego"; /* action label */ "Join room" = "Dołącz do pokoju"; /* channel status label */ "Joined" = "Dołączony"; /* channel join view operation label */ "Joining…" = "Dołączanie…"; /* report spam action */ "Just block" = "Tylko zablokuj"; /* no OMEMO key - not generated yet */ "Key not generated!" = "Klucz nie został wygenerowany!"; /* button label */ "Kick out" = "Wyrzuć"; /* option description */ "Large value may increase inital synchronization time" = "Duża wartość może wydłużyć czas pierwszej synchronizacji"; /* button label */ "Leave" = "Wyjdź"; /* leaving channel title */ "Leaving channel" = "Opuszczanie kanału"; /* appearance type */ "Light" = "Jasny"; /* option description */ "Limits the size of the files sent to you which may be automatically downloaded" = "Zapobiega automatycznemu pobieraniu większych plików"; /* memory usage label */ "Link previews" = "Podgląd linków"; /* section label */ "List of messages" = "Lista wiadomości"; /* attachemt label for conversations list */ "Location" = "Położenie"; /* error message */ "Login and password do not match." = "Login i hasło nie pasują."; /* video quality */ "Low" = "Niska"; /* muc error reason */ "Maximum number of users exceeded" = "Przekroczono maksymalną ilość uczestników"; /* me label for conversation log */ "Me" = "Ja"; /* video quality */ "Medium" = "Średnia"; /* alert title */ "Meeting ended" = "Spotkanie zakończone"; /* alert body */ "Meeting has ended" = "Spotkanie zostało zakończone"; /* muc error reason */ "Membership is required to access the room" = "Członkowstwo jest wymagane aby dołączyć do pokoju."; /* message decryption error message encryption error message encryption failure */ "Message decryption failed! Error code: %d" = "Deszyfrowanie wiadomości nie udało się! Kod błędu: %d"; /* alert body */ "Message moderation failed!" = "Moderacja wiadomości nie powiodła się!"; /* section label */ "Message synchronization" = "Synchronizacja wiadomości"; /* message encryption error */ "Message was not encrypted for this device" = "Wiadomość nie zosatła zaszyfrowana dla tego urządzenia"; /* message decryption error */ "Message was not encrypted for this device." = "Wiadomość nie zosatła zaszyfrowana dla tego urządzenia."; /* section label */ "Messages" = "Wiadomości"; /* alert title */ "Metadata storage" = "Pobrane metadane"; /* alert title */ "Missed call" = "Nieodebrane połączenie"; /* alert body */ "Missed incoming call from %@" = "Nieodebrane połączenie od %@"; /* context action label */ "Moderate" = "Moderuj"; /* list of users with this role */ "Moderators" = "Moderatorzy"; /* synchronization period value */ "Month" = "Miesiąc"; /* attachment cell context action */ "More…" = "Więcej…"; /* conversation notifications status */ "Muted" = "Wyciszone"; /* call state label */ "New call" = "Nowa połączenie"; /* notification of incoming message on locked screen */ "New message" = "Nowa wiadomość"; /* new message without content notification */ "New message!" = "Nowa wiadomość!"; /* label for chats list new converation action */ "New private group chat" = "Nowy prywatny czat grupowy"; /* label for chats list new converation action */ "New public group chat" = "Nowy publiczny chat grupowy"; /* alert title */ "Nickname" = "Nick"; /* muc error reason */ "Nickname already in use" = "Nick jest już używany"; /* muc error reason */ "Nickname is locked down" = "Nick jest zablokowany"; /* button label */ "No" = "Nie"; /* attachments view label */ "No attachments" = "Brak załączników"; /* channel join status view label */ "None" = "Brak"; /* channel status label */ "Not connected" = "Nie połączono"; /* channel status label */ "Not joined" = "Nie dołączono"; /* action label */ "Nothing" = "Nic"; /* muc room status user status */ "Offline" = "Rozłączony"; /* button label button lable */ "OK" = "OK"; /* option to remove all data from local storage option to remove data older than 7 days */ "Older than 7 days" = "Starsze niż 7 dni"; /* encryption type */ "OMEMO" = "OMEMO"; /* muc room status presence status user status */ "Online" = "Dostępny"; /* action label */ "Open chat" = "Otwórz czat"; /* alert title */ "Open URL" = "Otwórz URL"; /* vcard field label */ "Organization" = "Organizacja"; /* vcard field label */ "Organization role" = "Rola w organizacji"; /* video quality */ "Original" = "Oryginalna"; /* selection warning */ "Original quality will share image in the format in which it is stored on your phone and it may not be supported by every device." = "Wybranie jakości oryginalnej spowoduje wysłanie zdjęcia w formacie w którym jest przechowywany na urządzeniu. Format ten może być nie wspierany przez każde urządzenie."; /* selection warning */ "Original quality will share video in the format in which video is stored on your phone and it may not be supported by every device." = "Wybranie jakości oryginalnej spowoduje wysłanie filmu w formacie w którym jest przechowywany na urządzeniu. Format ten może być nie wspierany przez każde urządzenie."; /* memory usage label */ "Other apps" = "Inne aplikacje"; /* section label */ "Other devices fingerprints" = "Odciski innych urządzeń"; /* list of users with this role */ "Participants" = "Uczestnicy"; /* button label */ "Pass ownership" = "Zmień właściciela"; /* contact details section vcard section label */ "Phones" = "Telefony"; /* voice message state */ "Playing…" = "Odtwarzanie..."; /* instruction to fill out the form */ "Please fill this form" = "Proszę wypełnić formularz"; /* alert title */ "Please launch application from the home screen before continuing." = "Proszę uruchomić aplikację z głównego ekranu przed kontynuacją."; /* sharing error */ "Please try again later." = "Proszę spróbować ponownie później."; /* section label */ "Preferred domain name" = "Preferowana nazwa domeny"; /* operation label */ "Preparing…" = "Przygotowywanie…"; /* attachment cell context action */ "Preview" = "Podgląd"; /* action label */ "Private message" = "Wiadomość prywatna"; /* account registration error */ "Provided values are not acceptable" = "Podane wartości są nieakceptowalne"; /* alert title */ "Push notifications" = "Powiadomienia push"; /* alert title */ "Push Notifications" = "Powiadomienia Push"; /* alert body */ "Push notifications are enabled for %@. They need to be disabled before account can be removed and it is not possible to at this time. Please try again later." = "Powiadomienia push są włączone dla %@. Muszą zostać one wyłączone nim konto zostanie usunięte a nie jest to obecnie możliwe. Proszę spróbować później."; /* push notifications registration failure message */ "Push notifications not available" = "Powiadomienia push są niedostępne"; /* section label */ "Quality of uploaded media" = "Jakość udostępnianych multimedióws"; /* synchronization period value */ "Quarter" = "Kwartał"; /* alert title */ "Question" = "Pytanie"; /* label for chat marker */ "Received" = "Odebrana"; /* presence subscription request notification */ "Received presence subscription request from %@" = "Otrzymano zapytanie o subskrypcję dostępnośco od %@"; /* alert title body */ "Received presence subscription request from\n%@\non account %@" = "Otrzymano prośbę o subskrypcję dostępności od\n%@\ndla konta %@"; /* voice message state */ "Recorded: %@" = "Nagrano: %@"; /* voice message state */ "Recording…" = "Nagrywanie…"; /* voice message state */ "Recording… %@" = "Nagrywanie… %@"; /* channel block users view operation channel edit info operation channel participants view operation */ "Refreshing…" = "Odświeżanie…"; /* button label */ "Register" = "Rejestruj"; /* alert title */ "Registering account" = "Rejestracja konta"; /* alert title */ "Registration failure" = "Rejestracja nieudana"; /* account registration error */ "Registration is not supported by this server" = "Rejestracja jest niedostępna na tym serwerze"; /* button label */ "Reject" = "Odrzuć"; /* alert body */ "Remote server returned an error: %@" = "Zdalny serwer zwrócił błąd: %@"; /* option description */ "Removal of cached attachments may lead to increased usage of network, if attachment may need to be redownloaded, or to lost files, if they are no longer available at the server." = "Usunięcię zapisanych załączników może spowodować zwiększone zużycie transmisji danych, jeśli załączniki będą musiały być pobrane ponownie, lub utratą dostępu do plików, jeśli nie są już dostępne na serwerze."; /* alert body */ "Remove account from application?" = "Usuwanie konta z aplikacji?"; /* button label */ "Remove bookmark" = "Usuń zakładkę"; /* button label */ "Remove from application" = "Usuń z aplikacji"; /* button label */ "Remove from server" = "Usuń z serwera"; /* button label */ "Rename" = "Zmień nazwę"; /* alert title button label */ "Rename chat" = "Zmień nazwę czatu"; /* context action label */ "Reply…" = "Odpowiedz…"; /* context action label */ "Report & block…" = "Zgłoś i zablokuj…"; /* report abuse action */ "Report abuse" = "Zgłoś nadużycie"; /* report spam action */ "Report spam" = "Zgłoś spam"; /* button label */ "Resend" = "Wyślij ponownie"; /* context action label */ "Retract" = "Wycofaj"; /* call state label */ "Ringing…" = "Dzwonienie…"; /* alert title */ "Room %@" = "Pokój %@"; /* muc error reason */ "Room is locked" = "Pokój jest zablokowany"; /* alert body */ "Room was created and joined but room was not properly configured. Got following error: %@" = "Pokój został utworzony, ale nie udało się go skonfigurować. Wystąpił błąd: %@"; /* search bar placeholder */ "Search channels" = "Szukaj kanałów"; /* placeholder for location selection search bar */ "Search for places" = "Szukaj miejsc"; /* placeholder */ "Search to add…" = "Szukaj aby dodać…"; /* alert body */ "Select account to open chat from" = "Wybierz konto aby otworzyc rozmowę"; /* selection information */ "Select appearance" = "Wybiesz wygląd"; /* selection application icon information */ "Select application icon" = "Wybierz ikonę aplikacji"; /* title for multiple contact selection */ "Select contacts" = "Wybierz kontakty"; /* selection information */ "Select default conversation encryption" = "Wybierz domyślne szyfrowanie wiadomości"; /* location selection window title */ "Select location" = "Wybierz położenie"; /* selection description */ "Select period of messages to be synchronized" = "Wybierz okres synchronizacji wiadomości"; /* button label photo selection action */ "Select photo" = "Wybierz zdjęcie"; /* media quality selection instruction */ "Select quality" = "Wybierz jakość"; /* selection description */ "Select quality of the image to use for sharing" = "Wybierz jakość udostępnianych zdjęć"; /* selection description */ "Select quality of the video to use for sharing" = "Wybierz jakość udostępnianych filmów"; /* view title */ "Select recipients" = "Wybierz odbiorców"; /* alert title */ "Select status" = "Wybierz status"; /* button label */ "Send" = "Wyślij"; /* alert title */ "Send message" = "Wyślij wiadomość"; /* channel invitations view operation */ "Sending invitations…" = "Wyślij zaproszenia…"; /* operation label */ "Sending…" = "Wysyłanie…"; /* sharing error */ "Server did not confirm file upload correctly." = "Serwer nie potwierdził poprawnegp odebrania pliku."; /* ssl certificate alert dialog body */ "Server for domain %@ provided invalid certificate for %@\n with fingerprint\n%@%@.\nDo you trust this certificate?" = "Serwer dla domeny %@ przedstawił nieprawidłowy certyfikat dla %@\n z odciskiem\n%@%@.\nCzy zaufać temu certyfikatowi?"; /* alert title - unblock communication with server */ "Server is blocked" = "Serwer jest zablokowany"; /* alert body */ "Server of selected account does not provide support for hosting meetings. Please select a different account." = "Serwer dla wybranego konta nie wspiera hostingu spotkań. Proszę wybrać inne konto."; /* alert body */ "Server returned an error: %@" = "Serwer zwrócił błąd: %@"; /* account registration error */ "Service is not available at this time." = "Chwilowo usługa nie jest dostępna."; /* alert title */ "Service unavailable" = "Usługa niedostępna"; /* button label */ "Set" = "Ustaw"; /* contact details section section label */ "Settings" = "Ustawienia"; /* attachment cell context action */ "Share…" = "Udostępnij…"; /* section label */ "Sharing" = "Udostępnianie"; /* alert body */ "Sharing feature with HTTP upload is disabled within application. To use this feature you need to enable sharing with HTTP upload in application" = "Udostępnianie plików przez HTTP jest wyłączone w aplikacji. Aby użyć tej funkcjonalności, włącz udostępnianie plików przez HTTP w aplikacji"; /* alert body */ "Should account be removed from server as well?" = "Czy konto powinno być usunięte również z serwera?"; /* context action label */ "Show map" = "Pokaż na mapie"; /* notifications from unknown description */ "Show notifications from people not in your contact list" = "Pokaż powiadomienia od osób z poza listy kontaktów"; /* App icon */ "Simple" = "Prosta"; /* audio output label */ "Speaker" = "Głośnik"; /* alert title */ "Start chatting" = "Rozpocznij czat"; /* alert title section label */ "Status" = "Status"; /* alert title */ "Subscribe to %@" = "Zasubskrybuj %@"; /* alert title */ "Subscription request" = "Prośba o subskrypcję"; /* button label */ "Switch audio" = "Przełącz audio"; /* button label */ "Switch camera" = "Przełącz kamerę"; /* button label photo selection action */ "Take photo" = "Zrób zdjęcie"; /* report user message */ "The user %@ will be blocked. Should it be reported as well?" = "Użytkownik %@ zostanie zablokowny. Czy chcesz to również zgłosić?"; /* alert message */ "There is no service supporting channels for domain %@" = "Brak usługi wspierającej kanały dla domeny %@"; /* message encryption failure */ "There is no trusted device to send message to" = "Nie ma zaufanego urządzenia do którego można wysłać wiadomość"; /* alert body */ "This room is not capable of sending encrypted messages. Please change encryption settings to be able to send messages" = "Ten pokój nie wspiera przesyłania szyfrowanych wiadomości. Proszę zmienić ustawienia szyfrowania, aby móc wysyłać wiadomości"; /* conversation log groupchat direction label */ "To" = "Do"; /* section label */ "Trusted servers" = "Zaufane serwery"; /* error recovery suggestion */ "Try again. If removal failed, try accessing Keychain to update account credentials manually." = "Spróbuj ponownie. Jeśli usunięcie się nie powiedzie, spróbuj usunąć dane o koncie z poziomu Pęku kluczy."; /* synchronization period value */ "Two weeks" = "Dwa tygodnie"; /* action */ "Unblock" = "Odblokuj"; /* context menu action */ "Unblock server" = "Odblokuj serwer"; /* alert body */ "Unknown error occurred" = "Wystąpił nieznany błąd"; /* unknown file label */ "Unknown file" = "Nieznany plik"; /* allowed size of file to download */ "Unlimited" = "Nieograniczony"; /* conversation log label */ "Unread messages" = "Nieprzeczytane wiadomości"; /* channel block users view operation channel edit info operation refresh conrol label */ "Updating…" = "Aktualizacja…"; /* alert title */ "Upload failed" = "Przesył nie powiódł się"; /* sharing error */ "Upload to HTTP server failed." = "Nie udało się przesłać pliku do serwera HTTP"; /* option description */ "Used image and video quality may impact storage and network usage" = "Wybrana jakość zdjęć i plików może mieć wpływ na ilość zajętego miejsca na urządzeniu oraz na ilość przesyłanych danych"; /* alert body */ "User avatar publication failed.\nReason: %@" = "Publikacja avatara nie udała się.\nPowód: %@"; /* muc error reason */ "User is banned" = "Użytkownik jest zablokowany"; /* account registration error */ "User with provided username already exists" = "Użytkownik z podaną nazwą już istnieje"; /* account info label */ "using %@" = "używając %@"; /* alert body */ "VCard publication failed: %@" = "Nie udało się opublikować profilu: %@"; /* version of the app */ "Version: %@" = "Wersja: %@"; /* action label */ "Video call" = "Rozmowa wideo"; /* list of users with this role */ "Visitors" = "Goście"; /* alert title */ "Warning" = "Ostrzeżenie"; /* alert body used space label */ "We are using %@ of storage." = "Uzyto %@ przestrzeni."; /* synchronization period value */ "Week" = "Tydzień"; /* alert body */ "What do you want to do with %@?" = "Co chcesz zrobić z %@?"; /* conversation notifications status */ "When mentioned" = "Gdy wspomniano"; /* alert body */ "When you share files using HTTP, they are uploaded to HTTP server with unique URL. Anyone who knows the unique URL to the file is able to download it.\nDo you wish to enable?" = "Gdy udostępniasz pliki przez HTTP, są one przesyłane do serwera HTTP pod unikalny adres URL. Ktokolwiek zna ten adres, ten jest w stanie pobrać przesłany plik.\nCzy chcesz właczyć udostępnianie przez HTTP?"; /* alert body */ "When you share files, they are uploaded to HTTP server with unique URL. Anyone who knows the unique URL to the file is able to download it.\nDo you wish to proceed?" = "Gdy udostępniasz pliki, są one przesyłane do serwera HTTP pod unikalny adres URL. Ktokolwiek zna ten adres, ten jest w stanie pobrać przesłany plik.\nCzy chcesz kontynuować?"; /* address type address type label */ "Work" = "Praca"; /* synchronization period value */ "Year" = "Rok"; /* button label */ "Yes" = "Tak"; /* alert body */ "You are about to destroy channel %@. This will remove the channel on the server, remove remote history archive, and kick out all participants. Are you sure?" = "Zaraz zniszczysz kanał %@. To spowoduje usunięcie kanału na serwerze, usunięcie zdalnego archiwum rozmów i wyrzucenie wszystkich uczestników. Czy jesteś pewien?"; /* error label */ "You are invied to join conversation at %@" = "Dostałeś zaproszneie do rozmowy %@"; /* alert body */ "You are not connected to room.\nPlease wait reconnection to room" = "Nie dołączyłeś do pokoju.\nPoczekaj aż nastąpi połączenie"; /* alert body */ "You are not joined to the channel." = "Nie jesteś uczestnikiem kanału."; /* leaving channel text */ "You are the last person with ownership of this channel. Please decide what to do with the channel." = "Jesteś ostatnią osobą będącą właścicielem tego kanału. Zdecyduj co zrobić z kanałem."; /* push notifications not allowed warning */ "You need to allow application to show notifications and for background refresh." = "Musisz zezwolić aplikacji na powiadomienia oraz na odświeżanie w tle."; /* alert body */ "You've left there room %@ and push notifications for this room were disabled!\nYou may need to reenable them on other devices." = "Opuściłeś pokój %@ i powiadomienia push dla tego pokoju zostały wyłączone.\nMożesz musieć jest włączyć na innych urządzeniach."; /* search location pin label */ "Your location" = "Twoje położenie"; ================================================ FILE: SiskinIM/localization/pl.lproj/MIX.strings ================================================ /* Class = "UITextField"; placeholder = "Automatic"; ObjectID = "19A-3H-7QN"; */ "19A-3H-7QN.placeholder" = "Automatycznie"; /* Class = "UILabel"; text = "Use MIX"; ObjectID = "4P1-zT-Par"; */ "4P1-zT-Par.text" = "Użyj MIX"; /* Class = "UITableViewSection"; footerTitle = "Enter domain name of a component with channel or leave blank to automatically detect components with channels"; ObjectID = "94L-uC-ldZ"; */ "94L-uC-ldZ.footerTitle" = "Wprowadź domenę komponentu z kanałem lub pozostaw puste aby automatycznie wykryć domenę komponentu z kanałami"; /* Class = "UITableViewSection"; headerTitle = "Component domain"; ObjectID = "94L-uC-ldZ"; */ "94L-uC-ldZ.headerTitle" = "Domena komponentu"; /* Class = "UILabel"; text = "Create"; ObjectID = "9Ww-8v-Dml"; */ "9Ww-8v-Dml.text" = "Utwórz"; /* Class = "UITableViewSection"; headerTitle = "Experimental"; ObjectID = "aqR-Sm-2re"; */ "aqR-Sm-2re.headerTitle" = "Eksperymentalne"; /* Class = "UINavigationItem"; title = "Select channel"; ObjectID = "C5U-9C-poe"; */ "C5U-9C-poe.title" = "Wybierz kanał"; /* Class = "UITableViewSection"; headerTitle = "Bookmark"; ObjectID = "cOt-Oi-tab"; */ "cOt-Oi-tab.headerTitle" = "Zakładka"; /* Class = "UIBarButtonItem"; title = "Item"; ObjectID = "cv8-VS-8c6"; */ "cv8-VS-8c6.title" = "Item"; /* Class = "UILabel"; text = "Autojoin"; ObjectID = "da6-M6-5nm"; */ "da6-M6-5nm.text" = "Dołącz automatycznie"; /* Class = "UILabel"; text = "Notifications"; ObjectID = "fFa-pN-ndo"; */ "fFa-pN-ndo.text" = "Powiadomienia"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "gNa-Cz-T88"; Note = "Placeholder for a field to notify user that it needs to be filled"; */ "gNa-Cz-T88.placeholder" = "wymagane"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "Jiu-Fd-AsM"; */ "Jiu-Fd-AsM.placeholder" = "wymagane"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "kD7-lz-IEK"; Note = "Placeholder for a field to notify user that it needs to be filled"; */ "kD7-lz-IEK.placeholder" = "wymagane"; /* Class = "UITableViewController"; title = "Channel details"; ObjectID = "ke4-WK-unt"; */ "ke4-WK-unt.title" = "Szczegóły kanału"; /* Class = "UIBarButtonItem"; title = "Invite"; ObjectID = "Kre-sS-2vH"; */ "Kre-sS-2vH.title" = "Zaproś"; /* Class = "UITextField"; placeholder = "Description"; ObjectID = "MsF-6z-TY3"; */ "MsF-6z-TY3.placeholder" = "Opis"; /* Class = "UINavigationItem"; title = "Participants"; ObjectID = "mW8-st-X8N"; */ "mW8-st-X8N.title" = "Uczestniczy"; /* Class = "UITableViewSection"; headerTitle = "Access"; ObjectID = "mXt-Xt-Bj5"; */ "mXt-Xt-Bj5.headerTitle" = "Dostęp"; /* Class = "UILabel"; text = "Attachments"; ObjectID = "NWM-d4-jmq"; */ "NWM-d4-jmq.text" = "Załączniki"; /* Class = "UITableViewSection"; headerTitle = "Nickname"; ObjectID = "NyO-PD-t9d"; */ "NyO-PD-t9d.headerTitle" = "Nick"; /* Class = "UILabel"; text = "Delete channel"; ObjectID = "O9e-sP-5IO"; */ "O9e-sP-5IO.text" = "Usuń kanał"; /* Class = "UITextField"; placeholder = "required"; ObjectID = "oIZ-xz-SzZ"; */ "oIZ-xz-SzZ.placeholder" = "wymagane"; /* Class = "UIBarButtonItem"; title = "Join"; ObjectID = "Pgv-Uz-ZgP"; */ "Pgv-Uz-ZgP.title" = "Dołącz"; /* Class = "UIBarButtonItem"; title = "Next"; ObjectID = "pZC-YZ-jlg"; */ "pZC-YZ-jlg.title" = "Dalej"; /* Class = "UITableViewSection"; headerTitle = "Password"; ObjectID = "qB9-Eq-3RT"; */ "qB9-Eq-3RT.headerTitle" = "Hasło"; /* Class = "UITextField"; placeholder = "Name"; ObjectID = "r60-FV-hoE"; */ "r60-FV-hoE.placeholder" = "Nazwa"; /* Class = "UILabel"; text = "Change"; ObjectID = "Sso-1F-PI3"; */ "Sso-1F-PI3.text" = "Zmień"; /* Class = "UITableViewSection"; footerTitle = "Select which account should be used to join channel"; ObjectID = "SUF-pg-qbn"; */ "SUF-pg-qbn.footerTitle" = "Wybierz konto z którego chcesz dołączyć do kanału"; /* Class = "UITableViewSection"; headerTitle = "Account"; ObjectID = "SUF-pg-qbn"; */ "SUF-pg-qbn.headerTitle" = "Konto"; /* Class = "UITableViewSection"; footerTitle = "Notification support and filtering depends on the XMPP server which you are using and may not work in some cases even if it's enabled here."; ObjectID = "Ta8-dG-cwn"; */ "Ta8-dG-cwn.footerTitle" = "Wsparcie dla powiadomień i filtrowania zależy od serwera XMPP, który jest używany i może nie zawsze działać mimo, iż jest włączone tutaj."; /* Class = "UITableViewSection"; headerTitle = "Settings"; ObjectID = "Ta8-dG-cwn"; */ "Ta8-dG-cwn.headerTitle" = "Ustawienia"; /* Class = "UITableViewSection"; footerTitle = "ID of a channel used for joining (localpart of a JID)"; ObjectID = "u6K-qu-Pf5"; */ "u6K-qu-Pf5.footerTitle" = "ID kanału używane do dołączania (przed domeną komponentu)"; /* Class = "UITableViewSection"; headerTitle = "Channel ID"; ObjectID = "u6K-qu-Pf5"; */ "u6K-qu-Pf5.headerTitle" = "ID kanału"; /* Class = "UINavigationItem"; title = "Blocked"; ObjectID = "uMJ-O9-BGV"; */ "uMJ-O9-BGV.title" = "Zablokowani"; /* Class = "UIBarButtonItem"; title = "Next"; ObjectID = "uX9-lh-BwU"; */ "uX9-lh-BwU.title" = "Dalej"; /* Class = "UITableViewSection"; headerTitle = "Channel name"; ObjectID = "vcg-og-awz"; */ "vcg-og-awz.headerTitle" = "Nazwa kanału"; /* Class = "UIBarButtonItem"; title = "Item"; ObjectID = "VkQ-fL-ON5"; */ "VkQ-fL-ON5.title" = "Item"; /* Class = "UINavigationItem"; title = "Create channel"; ObjectID = "Vna-Fa-6lB"; */ "Vna-Fa-6lB.title" = "Utwórz kanał"; /* Class = "UILabel"; text = "Invitation only"; ObjectID = "Xcj-Am-jtk"; */ "Xcj-Am-jtk.text" = "Tylko z zaproszeniami"; ================================================ FILE: SiskinIM/localization/pl.lproj/Main.strings ================================================ /* Class = "UILabel"; text = "by Tigase, Inc."; ObjectID = "8ba-yZ-XRA"; */ "8ba-yZ-XRA.text" = "od Tigase, Inc."; /* Class = "UITabBarItem"; title = "Chats"; ObjectID = "acW-dT-cKf"; */ "acW-dT-cKf.title" = "Czaty"; /* Class = "UITextField"; placeholder = "Enter jid"; ObjectID = "BM3-28-huR"; */ "BM3-28-huR.placeholder" = "Wprowadź JID"; /* Class = "UINavigationItem"; title = "Informations"; ObjectID = "bNp-S8-ulX"; */ "bNp-S8-ulX.title" = "Informacje"; /* Class = "UINavigationItem"; title = "Attachments"; ObjectID = "C1j-4i-HDP"; */ "C1j-4i-HDP.title" = "Załączniki"; /* Class = "UITabBarItem"; title = "Bookmarks"; ObjectID = "dgT-Yo-7nF"; */ "dgT-Yo-7nF.title" = "Zakładki"; /* Class = "UILabel"; text = "Attachments"; ObjectID = "EpU-tc-DIx"; */ "EpU-tc-DIx.text" = "Załączniki"; /* Class = "UILabel"; text = "Ask for presence updates"; ObjectID = "ffo-7n-Tn2"; */ "ffo-7n-Tn2.text" = "Poproś o informacje o dostępności"; /* Class = "UILabel"; text = "Mute contact"; ObjectID = "g8N-kr-fax"; */ "g8N-kr-fax.text" = "Wycisz kontakt"; /* Class = "UITextField"; placeholder = "Select account"; ObjectID = "gXY-yq-Y2K"; */ "gXY-yq-Y2K.placeholder" = "Wybierz konto"; /* Class = "UITextField"; text = "local_user@example.com"; ObjectID = "gXY-yq-Y2K"; */ "gXY-yq-Y2K.text" = "local_user@example.com"; /* Class = "UITableViewSection"; headerTitle = "Name"; ObjectID = "Kfl-J5-hdD"; */ "Kfl-J5-hdD.headerTitle" = "Nazwa"; /* Class = "UILabel"; text = "Block contact"; ObjectID = "kwQ-p7-cX2"; */ "kwQ-p7-cX2.text" = "Zablokuj kontakt"; /* Class = "UITableViewSection"; headerTitle = "Account"; ObjectID = "Mfv-HJ-QDO"; */ "Mfv-HJ-QDO.headerTitle" = "Konto"; /* Class = "UICollectionViewController"; title = "Attachments"; ObjectID = "N9z-ms-iaT"; */ "N9z-ms-iaT.title" = "Załączniki"; /* Class = "UINavigationController"; title = "Contacts"; ObjectID = "Ndx-if-NHK"; */ "Ndx-if-NHK.title" = "Kontakty"; /* Class = "UITextField"; placeholder = "Enter display name"; ObjectID = "OGf-mX-8z3"; */ "OGf-mX-8z3.placeholder" = "Wprowadź nazwę wyświetlaną"; /* Class = "UITableViewSection"; headerTitle = "PRESENCE"; ObjectID = "Q28-Ig-9NP"; */ "Q28-Ig-9NP.headerTitle" = "Dostępność"; /* Class = "UITableViewSection"; headerTitle = "XMPP Address (JID)"; ObjectID = "Qgj-eQ-geg"; */ "Qgj-eQ-geg.headerTitle" = "Adres XMPP (JID)"; /* Class = "UITableViewController"; title = "Bookmarks"; ObjectID = "rba-pb-IiC"; */ "rba-pb-IiC.title" = "Zakładki"; /* Class = "UILabel"; text = "Disclose my online status"; ObjectID = "upM-mW-rMZ"; */ "upM-mW-rMZ.text" = "Udostępnij mój status online"; /* Class = "UITabBarItem"; title = "Contacts"; ObjectID = "W52-LN-wzX"; */ "W52-LN-wzX.title" = "Kontakty"; /* Class = "UIButton"; normalTitle = "Create new XMPP account"; ObjectID = "WyQ-cb-VAl"; */ "WyQ-cb-VAl.normalTitle" = "Utwórz nowe konto XMPP"; /* Class = "UILabel"; text = "Message encryption"; ObjectID = "XZp-oZ-XpC"; */ "XZp-oZ-XpC.text" = "Szyfrowanie wiadomości"; /* Class = "UIButton"; normalTitle = "Sign in to an existing XMPP account"; ObjectID = "ZzM-t7-zvW"; */ "ZzM-t7-zvW.normalTitle" = "Zaloguj do istniejącego konta XMPP"; ================================================ FILE: SiskinIM/localization/pl.lproj/Settings.strings ================================================ /* Class = "UINavigationController"; title = "Settings"; ObjectID = "15C-WY-qrp"; */ "15C-WY-qrp.title" = "Ustawienia"; /* Class = "UILabel"; text = "Chat markers & receipts"; ObjectID = "32B-bc-CHX"; */ "32B-bc-CHX.text" = "Znaczniki i potwierdzenia odbioru"; /* Class = "UILabel"; text = "Show emoticons"; ObjectID = "3a8-1d-3PR"; */ "3a8-1d-3PR.text" = "Pokaż emotikony"; /* Class = "UILabel"; text = "Notifications"; ObjectID = "3pd-ay-sAI"; */ "3pd-ay-sAI.text" = "Powiadomienia"; /* Class = "UILabel"; text = "Notifications from unknown"; ObjectID = "67H-51-GcY"; */ "67H-51-GcY.text" = "Powiadomienia od nieznanych"; /* Class = "UILabel"; text = "Media"; ObjectID = "8G3-tm-sCO"; */ "8G3-tm-sCO.text" = "Media"; /* Class = "UILabel"; text = "Enable markdown"; ObjectID = "8K5-bS-ddh"; */ "8K5-bS-ddh.text" = "Włącz markdown"; /* Class = "UILabel"; text = "File download limit"; ObjectID = "8WB-sA-XVp"; */ "8WB-sA-XVp.text" = "Limit pobierania pliku"; /* Class = "UILabel"; text = "Get in touch"; ObjectID = "91g-qn-FOS"; */ "91g-qn-FOS.text" = "Kontakt"; /* Class = "UILabel"; text = "About the app"; ObjectID = "9cK-3e-Nqx"; */ "9cK-3e-Nqx.text" = "O aplikacji"; /* Class = "UILabel"; text = "No blocked contacts"; ObjectID = "Aki-gv-qus"; */ "Aki-gv-qus.text" = "Brak zablokowanych kontaktów"; /* Class = "UILabel"; text = "Photos quality"; ObjectID = "bbT-7s-6ph"; */ "bbT-7s-6ph.text" = "Jakość zdjęć"; /* Class = "UILabel"; text = "Send message on Return"; ObjectID = "Bd4-qf-OsU"; */ "Bd4-qf-OsU.text" = "Wyślij wiadomość po Return"; /* Class = "UILabel"; text = "Videos quality"; ObjectID = "bKW-ln-Kez"; */ "bKW-ln-Kez.text" = "Jakość wideo"; /* Class = "UILabel"; text = "Use public STUN servers"; ObjectID = "BSa-RZ-M7L"; */ "BSa-RZ-M7L.text" = "Używaj publicznych serwerów STUN"; /* Class = "UILabel"; text = "Contacts in groups"; ObjectID = "BSk-tI-BDM"; */ "BSk-tI-BDM.text" = "Grupuj kontakty"; /* Class = "UILabel"; text = "Push notifications"; ObjectID = "cyb-st-Bmw"; */ "cyb-st-Bmw.text" = "Powiadomienia push"; /* Class = "UILabel"; text = "XMPP Quickstart / Pipelining"; ObjectID = "dbq-Tl-d6b"; */ "dbq-Tl-d6b.text" = "XMPP Quickstart / Pipelining"; /* Class = "UILabel"; text = "Chats"; ObjectID = "etv-3w-9lA"; */ "etv-3w-9lA.text" = "Czaty"; /* Class = "UILabel"; text = "File sharing via HTTP"; ObjectID = "eZJ-fR-LPh"; */ "eZJ-fR-LPh.text" = "Udostępnianie plików przez HTTP"; /* Class = "UITableViewController"; title = "Experimental"; ObjectID = "FGQ-GL-dYt"; */ "FGQ-GL-dYt.title" = "Eksperymentalne"; /* Class = "UILabel"; text = "Appearance"; ObjectID = "fxY-aA-n0i"; */ "fxY-aA-n0i.text" = "Wygląd"; /* Class = "UIBarButtonItem"; title = "Close"; ObjectID = "G2W-rB-KuE"; */ "G2W-rB-KuE.title" = "Zamknij"; /* Class = "UILabel"; text = "2 lines"; ObjectID = "g4v-o5-nXZ"; */ "g4v-o5-nXZ.text" = "2 linie"; /* Class = "UITableViewController"; title = "Notification settings"; ObjectID = "GfS-6V-cuc"; */ "GfS-6V-cuc.title" = "Ustawienia powiadomień"; /* Class = "UILabel"; text = "Contacts"; ObjectID = "hGX-FI-YNj"; */ "hGX-FI-YNj.text" = "Kontakty"; /* Class = "UILabel"; text = "Clear download cache"; ObjectID = "Hu1-2i-RSO"; */ "Hu1-2i-RSO.text" = "Wyczyść cache pobierania"; /* Class = "UILabel"; text = "Media"; ObjectID = "ieE-mK-uEm"; */ "ieE-mK-uEm.text" = "Media"; /* Class = "UINavigationItem"; title = "Settings"; ObjectID = "jeY-X9-tWB"; */ "jeY-X9-tWB.title" = "Ustawienia"; /* Class = "UILabel"; text = "Auto-authorize contacts"; ObjectID = "MGl-L6-Fs3"; */ "MGl-L6-Fs3.text" = "Automatycznie autoryzuj kontakty"; /* Class = "UILabel"; text = "Blocked contacts"; ObjectID = "MJ0-kw-1Kl"; */ "MJ0-kw-1Kl.text" = "Zablokowane kontakty"; /* Class = "UILabel"; text = "\"Hidden\" group"; ObjectID = "P82-B8-768"; */ "P82-B8-768.text" = "Grupa \"Hidden\""; /* Class = "UITableViewController"; title = "Chat settings"; ObjectID = "pFo-ox-4gT"; */ "pFo-ox-4gT.title" = "Ustawienia czatów"; /* Class = "UILabel"; text = "Groupchats bookmarks sync"; ObjectID = "Tbt-L3-jpa"; */ "Tbt-L3-jpa.text" = "Synchronizacja zakładek czatów grupowych"; /* Class = "UILabel"; text = "Icon"; ObjectID = "X1h-yo-uZt"; */ "X1h-yo-uZt.text" = "Ikona"; /* Class = "UITableViewController"; title = "Contacts settings"; ObjectID = "xRS-v5-T5C"; */ "xRS-v5-T5C.title" = "Ustawienia kontaktów"; /* Class = "UILabel"; text = "Sorting"; ObjectID = "Y0J-o4-ADK"; */ "Y0J-o4-ADK.text" = "Sortowanie"; /* Class = "UILabel"; text = "Show link previews"; ObjectID = "ybc-mt-hVG"; */ "ybc-mt-hVG.text" = "Pokaż podgląd łączy"; /* Class = "UILabel"; text = "Experimental"; ObjectID = "YDd-q8-r7f"; */ "YDd-q8-r7f.text" = "Eksperymentalne"; /* Class = "UILabel"; text = "Clear link previews cache"; ObjectID = "Zff-Y8-Nz6"; */ "Zff-Y8-Nz6.text" = "Wyczyść cache podglądu łączy"; /* Class = "UILabel"; text = "Encryption"; ObjectID = "ZnP-5r-iGO"; */ "ZnP-5r-iGO.text" = "Szyfrowanie"; /* Class = "UILabel"; text = "Message carbons"; ObjectID = "zwR-jn-5u6"; */ "zwR-jn-5u6.text" = "Message carbons"; ================================================ FILE: SiskinIM/localization/pl.lproj/VoIP.strings ================================================ /* Class = "UITableViewSection"; footerTitle = "Select which account should be used to join channel"; ObjectID = "0d6-UW-iX3"; */ "0d6-UW-iX3.footerTitle" = "Wybierz konto z którego chcesz dołączyć do kanału"; /* Class = "UITableViewSection"; headerTitle = "Account"; ObjectID = "0d6-UW-iX3"; */ "0d6-UW-iX3.headerTitle" = "Konto"; /* Class = "UITextField"; text = "test@example.com"; ObjectID = "9Dn-ee-8WK"; */ "9Dn-ee-8WK.text" = "test@example.com"; ================================================ FILE: SiskinIM/notifications/NotificationCenterDelegate.swift ================================================ // // NotificationCenterDelegate.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Shared import WebRTC import Martin import UserNotifications import TigaseLogging class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate { private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationCenterDelegate"); func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { switch NotificationCategory.from(identifier: notification.request.content.categoryIdentifier) { case .MESSAGE: let account = notification.request.content.userInfo["account"] as? String; let sender = notification.request.content.userInfo["sender"] as? String; if (AppDelegate.isChatVisible(account: account, with: sender) && XmppService.instance.applicationState == .active) { completionHandler([]); } else { completionHandler([.alert, .sound]); } default: completionHandler([.alert, .sound]); } } func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let content = response.notification.request.content; switch NotificationCategory.from(identifier: response.notification.request.content.categoryIdentifier) { case .ERROR: didReceive(error: content, withCompletionHandler: completionHandler); case .SUBSCRIPTION_REQUEST: didReceive(subscriptionRequest: content, withCompletionHandler: completionHandler); case .MUC_ROOM_INVITATION: didReceive(mucInvitation: content, withCompletionHandler: completionHandler); case .MESSAGE: didReceive(messageResponse: response, withCompletionHandler: completionHandler); case .CALL: didReceive(call: content, withCompletionHandler: completionHandler); case .UNSENT_MESSAGES: completionHandler(); case .UNKNOWN: self.logger.error("received unknown notification category: \( response.notification.request.content.categoryIdentifier)"); completionHandler(); } } func topController() -> UIViewController? { var controler: UIViewController? = UIApplication.shared.windows.first(where: { $0.isKeyWindow })?.rootViewController; while (controler?.presentedViewController != nil) { controler = controler?.presentedViewController; } return controler; } func didReceive(error content: UNNotificationContent, withCompletionHandler completionHandler: @escaping () -> Void) { let userInfo = content.userInfo; if userInfo["cert-name"] != nil { let accountJid = BareJID(userInfo["account"] as! String); let alert = CertificateErrorAlert.create(domain: accountJid.domain, certName: userInfo["cert-name"] as! String, certHash: userInfo["cert-hash-sha1"] as! String, issuerName: userInfo["issuer-name"] as? String, issuerHash: userInfo["issuer-hash-sha1"] as? String, onAccept: { guard var account = AccountManager.getAccount(for: accountJid) else { return; } let certInfo = account.serverCertificate; certInfo?.accepted = true; account.serverCertificate = certInfo; account.active = true; AccountSettings.lastError(for: accountJid, value: nil); do { try AccountManager.save(account: account); } catch { let alert = UIAlertController(title: NSLocalizedString("Error", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("It was not possible to save account details: %@ Please try again later.", comment: "alert title body"), error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button lable"), style: .cancel, handler: nil)); self.topController()?.present(alert, animated: true, completion: nil); } }, onDeny: nil); topController()?.present(alert, animated: true, completion: nil); } if let authError = userInfo["auth-error-type"] { let accountJid = BareJID(userInfo["account"] as! String); let alert = UIAlertController(title: NSLocalizedString("Authentication issue", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Authentication for account %@ failed: %@\nVerify provided account password.", comment: "alert title body"), accountJid.stringValue, String(describing: authError)), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .cancel, handler: nil)); topController()?.present(alert, animated: true, completion: nil); } else { let alert = UIAlertController(title: content.title, message: content.body, preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .cancel, handler: nil)); topController()?.present(alert, animated: true, completion: nil); } completionHandler(); } func didReceive(subscriptionRequest content: UNNotificationContent, withCompletionHandler completionHandler: @escaping () -> Void) { let userInfo = content.userInfo; let senderJid = BareJID(userInfo["sender"] as! String); let accountJid = BareJID(userInfo["account"] as! String); var senderName = userInfo["senderName"] as! String; if senderName != senderJid.stringValue { senderName = "\(senderName) (\(senderJid.stringValue))"; } let alert = UIAlertController(title: NSLocalizedString("Subscription request", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Received presence subscription request from\n%@\non account %@", comment: "alert title body"), senderName, accountJid.stringValue), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("Accept", comment: "button label"), style: .default, handler: {(action) in guard let client = XmppService.instance.getClient(for: accountJid) else { return; } let presenceModule = client.module(.presence); presenceModule.subscribed(by: JID(senderJid)); let subscription = DBRosterStore.instance.item(for: client.context, jid: JID(senderJid))?.subscription ?? .none; guard !subscription.isTo else { return; } if Settings.autoSubscribeOnAcceptedSubscriptionRequest { presenceModule.subscribe(to: JID(senderJid)); } else { let alert2 = UIAlertController(title: String.localizedStringWithFormat(NSLocalizedString("Subscribe to %@", comment: "alert title"), senderName), message: String.localizedStringWithFormat(NSLocalizedString("Do you wish to subscribe to \n%@\non account %@", comment: "alert body"), senderName, accountJid.stringValue), preferredStyle: .alert); alert2.addAction(UIAlertAction(title: NSLocalizedString("Accept", comment: "button label"), style: .default, handler: {(action) in presenceModule.subscribe(to: JID(senderJid)); })); alert2.addAction(UIAlertAction(title: NSLocalizedString("Reject", comment: "button label"), style: .destructive, handler: nil)); self.topController()?.present(alert2, animated: true, completion: nil); } })); alert.addAction(UIAlertAction(title: NSLocalizedString("Reject", comment: "button label"), style: .destructive, handler: {(action) in guard let client = XmppService.instance.getClient(for: accountJid) else { return; } client.module(.presence).unsubscribed(by: JID(senderJid)); })); if let blockingCommandModule = XmppService.instance.getClient(for: accountJid)?.module(.blockingCommand), blockingCommandModule.isAvailable { guard let client = XmppService.instance.getClient(for: accountJid) else { return; } if blockingCommandModule.isReportingSupported { alert.addAction(UIAlertAction(title: NSLocalizedString("Block and report", comment: "button label"), style: .destructive, handler: { action in let alert2 = UIAlertController(title: String.localizedStringWithFormat(NSLocalizedString("Block and report", comment: "report user title"), senderJid.stringValue), message: String.localizedStringWithFormat(NSLocalizedString("The user %@ will be blocked. Should it be reported as well?", comment: "report user message"), senderJid.stringValue), preferredStyle: .alert) alert2.addAction(UIAlertAction(title: NSLocalizedString("Report spam", comment: "report spam action"), style: .default, handler: { _ in client.module(.presence).unsubscribed(by: JID(senderJid)) blockingCommandModule.block(jid: JID(senderJid), report: .init(cause: .spam), completionHandler: { result in }); })) alert2.addAction(UIAlertAction(title: NSLocalizedString("Report abuse", comment: "report abuse action"), style: .default, handler: { _ in client.module(.presence).unsubscribed(by: JID(senderJid)) blockingCommandModule.block(jid: JID(senderJid), report: .init(cause: .abuse), completionHandler: { result in }); })) alert2.addAction(UIAlertAction(title: NSLocalizedString("Just block", comment: "report spam action"), style: .default, handler: { _ in client.module(.presence).unsubscribed(by: JID(senderJid)) blockingCommandModule.block(jid: JID(senderJid), completionHandler: { result in }); })) self.topController()?.present(alert2, animated: true, completion: nil); })) } else { alert.addAction(UIAlertAction(title: NSLocalizedString("Block", comment: "button label"), style: .destructive, handler: { action in client.module(.presence).unsubscribed(by: JID(senderJid)) blockingCommandModule.block(jids: [JID(senderJid)], completionHandler: { result in }); })); } } topController()?.present(alert, animated: true, completion: nil); completionHandler(); } func didReceive(mucInvitation content: UNNotificationContent, withCompletionHandler completionHandler: @escaping () -> Void) { guard let account = BareJID(content.userInfo["account"] as? String), let roomJid: BareJID = BareJID(content.userInfo["roomJid"] as? String) else { return; } let password = content.userInfo["password"] as? String; let controller = UIStoryboard(name: "MIX", bundle: nil).instantiateViewController(withIdentifier: "ChannelJoinViewController") as! ChannelJoinViewController; controller.client = XmppService.instance.getClient(for: account); controller.channelJid = roomJid; controller.componentType = .muc; controller.password = password; controller.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: controller, action: #selector(ChannelJoinViewController.cancelClicked(_:))); let navController = UINavigationController(rootViewController: controller); navController.modalPresentationStyle = .formSheet; topController()?.present(navController, animated: true, completion: nil); completionHandler(); } func didReceive(messageResponse response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let userInfo = response.notification.request.content.userInfo; guard let accountJid = BareJID(userInfo["account"] as? String) else { completionHandler(); return; } guard let senderJid = BareJID(userInfo["sender"] as? String) else { NotificationManager.instance.updateApplicationIconBadgeNumber(completionHandler: completionHandler); return; } if response.actionIdentifier == UNNotificationDismissActionIdentifier { NotificationManager.instance.updateApplicationIconBadgeNumber(completionHandler: completionHandler); } else { openChatView(on: accountJid, with: senderJid, completionHandler: completionHandler); } } private func openChatView(on account: BareJID, with jid: BareJID, completionHandler: @escaping ()->Void) { var topController = UIApplication.shared.windows.first(where: { $0.isKeyWindow })?.rootViewController; while (topController?.presentedViewController != nil) { if let tmp = topController?.presentedViewController, tmp.modalPresentationStyle != .none { tmp.dismiss(animated: true, completion: { self.openChatView(on: account, with: jid, completionHandler: completionHandler); }); return; } else { topController = topController?.presentedViewController; } } if topController != nil { guard let conversation = DBChatStore.instance.conversation(for: account, with: jid), let controller = viewController(for: conversation) else { completionHandler(); return; } let navigationController = controller; let destination = navigationController.visibleViewController ?? controller; if let baseChatViewController = destination as? BaseChatViewController { baseChatViewController.conversation = conversation; } destination.hidesBottomBarWhenPushed = true; if let chatController = AppDelegate.getChatController(visible: false), let navController = chatController.parent as? UINavigationController { navController.pushViewController(destination, animated: true); var viewControllers = navController.viewControllers; if !viewControllers.isEmpty { var i = 0; while viewControllers[i] != chatController { i = i + 1; } while (!viewControllers.isEmpty) && i > 0 && viewControllers[i] != destination { viewControllers.remove(at: i); i = i - 1; } navController.viewControllers = viewControllers; } } else { topController!.showDetailViewController(controller, sender: self); } } else { self.logger.error("No top controller!"); } } private func viewController(for item: Conversation) -> UINavigationController? { switch item { case is Room: return UIStoryboard(name: "Groupchat", bundle: nil).instantiateViewController(withIdentifier: "RoomViewNavigationController") as? UINavigationController; case is Chat: return UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "ChatViewNavigationController") as? UINavigationController; case is Channel: return UIStoryboard(name: "MIX", bundle: nil).instantiateViewController(withIdentifier: "ChannelViewNavigationController") as? UINavigationController; default: return nil; } } func didReceive(call content: UNNotificationContent, withCompletionHandler completionHandler: @escaping () -> Void) { #if targetEnvironment(simulator) #else let userInfo = content.userInfo; let senderName = userInfo["senderName"] as! String; let senderJid = JID(userInfo["sender"] as! String); let accountJid = BareJID(userInfo["account"] as! String); let sdp = userInfo["sdpOffer"] as! String; let sid = userInfo["sid"] as! String; if let session = JingleManager.instance.session(for: accountJid, with: senderJid, sid: sid) { // can still can be received! let alert = UIAlertController(title: NSLocalizedString("Incoming call", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Incoming call from %@", comment: "alert body"), senderName), preferredStyle: .alert); switch AVCaptureDevice.authorizationStatus(for: .video) { case .denied, .restricted: break; default: break; // alert.addAction(UIAlertAction(title: "Video call", style: .default, handler: { action in // // accept video // VideoCallController.accept(session: session, sdpOffer: sdp, withAudio: true, withVideo: true, sender: topController!); // })) } // alert.addAction(UIAlertAction(title: "Audio call", style: .default, handler: { action in // VideoCallController.accept(session: session, sdpOffer: sdp, withAudio: true, withVideo: false, sender: topController!); // })); alert.addAction(UIAlertAction(title: NSLocalizedString("Dismiss", comment: "button label"), style: .cancel, handler: { action in session.decline(); })); topController()?.present(alert, animated: true, completion: nil); } else { // call missed... let alert = UIAlertController(title: NSLocalizedString("Missed call", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Missed incoming call from %@", comment: "alert body"), senderName), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); topController()?.present(alert, animated: true, completion: nil); } #endif completionHandler(); } } ================================================ FILE: SiskinIM/notifications/NotificationManager.swift ================================================ // // NotificationManager.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import UserNotifications import os import Shared import Combine import TigaseLogging public class NotificationManager { public static let instance: NotificationManager = NotificationManager(); public let provider: NotificationManagerProvider!; private var queues: [NotificationQueueKey: NotificationQueue] = [:]; private let dispatcher = QueueDispatcher(label: "NotificationManager"); private var cancellables: Set = []; private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationManager"); private init() { self.provider = MainNotificationManagerProvider(); MessageEventHandler.eventsPublisher.receive(on: dispatcher.queue).sink(receiveValue: { [weak self] event in switch event { case .started(let account, let jid): self?.syncStarted(for: account, with: jid); case .finished(let account, let jid): self?.syncCompleted(for: account, with: jid); } }).store(in: &cancellables); DBChatHistoryStore.instance.markedAsRead.receive(on: dispatcher.queue).sink(receiveValue: { [weak self] marked in self?.markAsRead(on: marked.account, with: marked.jid, itemsIds: marked.messages.map({ $0.id }), before: marked.before); }).store(in: &cancellables); DBChatStore.instance.$unreadMessagesCount.delay(for: 0.1, scheduler: self.dispatcher.queue).throttle(for: 0.1, scheduler: self.dispatcher.queue, latest: true).sink(receiveValue: { [weak self] value in self?.updateApplicationIconBadgeNumber(completionHandler: nil); }).store(in: &cancellables); NotificationCenter.default.publisher(for: XmppService.AUTHENTICATION_ERROR).sink(receiveValue: { [weak self] notification in let account = notification.object as! BareJID; let error = notification.userInfo!["error"] as! SaslError; self?.authentication(error: error, on: account); }).store(in: &cancellables); } private func authentication(error: SaslError, on account: BareJID) { let content = UNMutableNotificationContent(); content.body = String.localizedStringWithFormat(NSLocalizedString("Authentication for account %@ failed: %@", comment: "notification warning about authentication failure"), account.stringValue, error.rawValue); content.userInfo = ["auth-error-type": error.rawValue, "account": account.stringValue]; content.categoryIdentifier = "ERROR"; content.threadIdentifier = "account=" + account.stringValue; UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)); } // public func shouldShowNotification(account: BareJID, sender: BareJID?, body: String?, completionHandler: @escaping (Bool)->Void) { // provider.shouldShowNotification(account: account, sender: sender, body: body) { (result) in // if result { // if let uid = NotificationsManagerHelper.generateMessageUID(account: account, sender: sender, body: body) { // UNUserNotificationCenter.current().getDeliveredNotifications(completionHandler: { notifications in // let should = !notifications.contains(where: { (notification) -> Bool in // guard let nuid = notification.request.content.userInfo["uid"] as? String else { // return false; // } // return nuid == uid; // }); // completionHandler(should); // }); // return; // } // } // completionHandler(result); // } // } func newMessage(_ entry: ConversationEntry) { dispatcher.async { guard entry.shouldNotify() else { return; } if let queue = self.queues[.init(account: entry.conversation.account, jid: entry.conversation.jid)] ?? self.queues[.init(account: entry.conversation.account, jid: nil)] { queue.add(message: entry); } else { self.notifyNewMessage(message: entry); } } } public func dismissAllNotifications(on account: BareJID, with jid: BareJID) { let threadId = "account=\(account.stringValue)|sender=\(jid.stringValue)"; UNUserNotificationCenter.current().getDeliveredNotifications { notifications in let toRemove = notifications.filter({ (notification) -> Bool in return notification.request.content.threadIdentifier == threadId; }).map({ (notification) -> String in return notification.request.identifier; }); UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: toRemove); self.updateApplicationIconBadgeNumber(completionHandler: nil); } } private func markAsRead(on account: BareJID, with jid: BareJID, itemsIds: [Int], before date: Date) { if let queue = self.queues[.init(account: account, jid: jid)] { queue.cancel(forIds: itemsIds); } if let queue = self.queues[.init(account: account, jid: nil)] { queue.cancel(forIds: itemsIds); } // let ids = itemsIds.map({ "message:\($0):new" }); // UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids); let threadId = "account=\(account.stringValue)|sender=\(jid.stringValue)"; UNUserNotificationCenter.current().getDeliveredNotifications { notifications in let toRemove = notifications.filter({ (notification) -> Bool in guard notification.request.content.threadIdentifier == threadId else { return false; } guard let notificationDate = notification.request.content.userInfo["timestamp"] as? Date else { return false; } return notificationDate < date; }).map({ (notification) -> String in return notification.request.identifier; }); UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: toRemove); self.updateApplicationIconBadgeNumber(completionHandler: nil); } } private func syncStarted(for account: BareJID, with jid: BareJID?) { dispatcher.async { let key = NotificationQueueKey(account: account, jid: jid); if self.queues[key] == nil { self.queues[key] = NotificationQueue(); } } } private func syncCompleted(for account: BareJID, with jid: BareJID?) { dispatcher.async { if let messages = self.queues.removeValue(forKey: .init(account: account, jid: jid))?.unreadMessages { for message in messages { self.notifyNewMessage(message: message); } } } } public func notifyNewMessage(account: BareJID, sender jid: BareJID?, nickname: String?, body: String, date: Date) { let id = UUID().uuidString; let content = UNMutableNotificationContent(); NotificationsManagerHelper.prepareNewMessageNotification(content: content, account: account, sender: jid, nickname: nickname, body: body, provider: provider) { (content) in UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: id, content: content, trigger: nil)) { (error) in if let err = error { self.logger.error("message notification error \(err.localizedDescription)"); } } } } private func notifyNewMessage(message entry: ConversationEntry) { guard let conversation = entry.conversation as? Conversation else { return; } guard let body = entry.notificationContent else { return; } notifyNewMessage(account: conversation.account, sender: conversation.jid, nickname: entry.sender.nickname, body: body, date: entry.timestamp); } func updateApplicationIconBadgeNumber(completionHandler: (()->Void)?) { provider.countBadge(withThreadId: nil, completionHandler: { count in DispatchQueue.main.async { self.logger.debug("setting badge to: \(count)"); UIApplication.shared.applicationIconBadgeNumber = count; completionHandler?(); } }); } struct NotificationQueueKey: Hashable { let account: BareJID; let jid: BareJID?; func hash(into hasher: inout Hasher) { hasher.combine(account); if let jid = jid { hasher.combine(jid); } } } class NotificationQueue { private(set) var unreadMessages: [ConversationEntry] = []; func add(message: ConversationEntry) { unreadMessages.append(message); } func cancel(forIds: [Int]) { let ids = Set(forIds); unreadMessages.removeAll(where: { ids.contains($0.id) }); } } } extension ConversationEntry { func shouldNotify() -> Bool { guard case .incoming(let state) = self.state, state == .received else { return false; } guard let conversation = self.conversation as? Conversation else { return false; } switch payload { case .message(let message, _): switch conversation.notifications { case .none: return false; case .mention: if let nickname = (conversation as? Room)?.nickname ?? (conversation as? Channel)?.nickname { if !message.contains(nickname) { return false; } } else { return false; } default: break; } case .location(_): guard conversation.notifications == .always else { return false; } case .attachment(_, _): guard conversation.notifications == .always else { return false; } default: return false; } if conversation is Chat { guard Settings.notificationsFromUnknown || conversation.displayName != conversation.jid.stringValue else { return false; } } return true; } var notificationContent: String? { switch self.payload { case .message(let message, _): return message; case .invitation(_, _): return "📨 \(NSLocalizedString("Invitation", comment: "invitation label for chats list"))" case .location(_): return "📍 \(NSLocalizedString("Location", comment: "attachemt label for conversations list"))"; case .attachment(_, _): return "📎 \(NSLocalizedString("Attachment", comment: "attachemt label for conversations list"))"; default: return nil; } } } ================================================ FILE: SiskinIM/roster/AbstractRosterViewController.swift ================================================ // // AbstractRosterViewController.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class AbstractRosterViewController: UITableViewController, UISearchResultsUpdating, UISearchBarDelegate { var searchController: UISearchController!; var roster: RosterProvider?; override func viewDidLoad() { super.viewDidLoad() searchController = UISearchController(searchResultsController: nil); searchController.obscuresBackgroundDuringPresentation = false; searchController.hidesNavigationBarDuringPresentation = false; searchController.searchResultsUpdater = self; searchController.searchBar.searchBarStyle = .prominent; searchController.searchBar.isOpaque = false; searchController.searchBar.isTranslucent = true; refreshControl?.isOpaque = false; navigationItem.searchController = self.searchController; //tableView.rowHeight = 48;//UITableViewAutomaticDimension; self.navigationItem.hidesSearchBarWhenScrolling = true; } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. if !self.isBeingPresented { roster = nil; } } override func viewWillAppear(_ animated: Bool) { initializeRosterProvider(); super.viewWillAppear(animated); } func initializeRosterProvider() { self.roster?.release(); switch Settings.rosterType { case .flat: roster = RosterProviderFlat(controller: self); case .grouped: roster = RosterProviderGrouped(controller: self); } self.tableView.reloadData(); } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated); NotificationCenter.default.removeObserver(self); roster = nil; } override func numberOfSections(in: UITableView) -> Int { return roster?.numberOfSections() ?? 0; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return roster?.numberOfRows(in: section) ?? 0; } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { return roster?.sectionHeader(at: section); } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cellIdentifier = "RosterItemTableViewCell"; let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as! RosterItemTableViewCell; if let item = roster?.item(at: indexPath) { cell.nameLabel.text = item.displayName; cell.statusLabel.text = item.account.stringValue; cell.avatarStatusView.displayableId = ContactManager.instance.contact(for: .init(account: item.account, jid: item.jid, type: .buddy)); } return cell; } override func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { if let v = view as? UITableViewHeaderFooterView { v.textLabel?.font = UIFont.preferredFont(forTextStyle: .subheadline); v.textLabel?.text = v.textLabel?.text?.uppercased(); v.textLabel?.textColor = UIColor.white; v.isOpaque = true; v.tintColor = UIColor(named: "chatslistBackground")?.lighter(ratio: 0.1); } } func updateSearchResults(for searchController: UISearchController) { roster?.queryItems(contains: searchController.searchBar.text); tableView.reloadData(); } } ================================================ FILE: SiskinIM/roster/RosterItemEditViewController.swift ================================================ // // RosterItemEditViewController.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class RosterItemEditViewController: UITableViewController, UIPickerViewDataSource, UIPickerViewDelegate { @IBOutlet var accountTextField: UITextField! @IBOutlet var jidTextField: UITextField! @IBOutlet var nameTextField: UITextField! @IBOutlet var sendPresenceUpdatesSwitch: UISwitch! @IBOutlet var receivePresenceUpdatesSwitch: UISwitch! var account:BareJID?; var jid:JID?; var preauth: String?; override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view let accountPicker = UIPickerView(); accountPicker.dataSource = self; accountPicker.delegate = self; self.accountTextField.inputView = accountPicker; // self.accountTextField.addTarget(self, action: #selector(RosterItemEditViewController.textFieldDidChange), for: UIControlEvents.editingChanged); // self.jidTextField.addTarget(self, action: #selector(RosterItemEditViewController.textFieldDidChange), for: UIControlEvents.editingChanged); self.jidTextField.text = jid?.stringValue; self.accountTextField.text = account?.stringValue; self.sendPresenceUpdatesSwitch.isOn = true; self.receivePresenceUpdatesSwitch.isOn = true;//Settings.AutoSubscribeOnAcceptedSubscriptionRequest.getBool(); if let account = account, let jid = jid { self.jidTextField.isEnabled = false; self.accountTextField.isEnabled = false; if let rosterItem = DBRosterStore.instance.item(for: account, jid: jid) { self.nameTextField.text = rosterItem.name; self.sendPresenceUpdatesSwitch.isOn = rosterItem.subscription.isFrom; self.receivePresenceUpdatesSwitch.isOn = rosterItem.subscription.isTo; } } else { if account == nil && !AccountManager.getAccounts().isEmpty { self.account = AccountManager.getAccounts().first; self.accountTextField.text = account?.stringValue; } self.nameTextField.text = nil; } } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } // func textFieldDidChange(_ textField: UITextField) { // if textField.text?.isEmpty != false { // textField.superview?.backgroundColor = UIColor.red; //// textField.layer.borderColor = UIColor.red.cgColor; //// textField.layer.borderWidth = 1; // } else { //// textField.layer.borderColor = UIColor(white: 1, alpha: 1).cgColor; //// textField.layer.borderWidth = 0; // textField.superview?.backgroundColor = UIColor.white; // } // } @IBAction func saveBtnClicked(_ sender: UIBarButtonItem) { saveChanges() } @IBAction func cancelBtnClicked(_ sender: UIBarButtonItem) { dismissView(); } func dismissView() { self.dismiss(animated: true, completion: nil); } func blinkError(_ field: UITextField) { let backgroundColor = field.superview?.backgroundColor; UIView.animate(withDuration: 0.5, animations: { //cell.backgroundColor = UIColor(red: 1.0, green: 0.5, blue: 0.5, alpha: 1); field.superview?.backgroundColor = UIColor(hue: 0, saturation: 0.7, brightness: 0.8, alpha: 1) }, completion: {(b) in UIView.animate(withDuration: 0.5) { field.superview?.backgroundColor = backgroundColor; } }); } func saveChanges() { var fieldsWithErrors: [UITextField] = []; if JID((jidTextField.text?.isEmpty ?? true) ? nil : jidTextField.text) == nil { fieldsWithErrors.append(jidTextField); } if BareJID((accountTextField.text?.isEmpty ?? true) ? nil : accountTextField.text) == nil { fieldsWithErrors.append(accountTextField); } guard fieldsWithErrors.isEmpty else { fieldsWithErrors.forEach(self.blinkError(_:)); return; } jid = JID(jidTextField.text!); account = BareJID(accountTextField.text!); guard let client = XmppService.instance.getClient(for: account!) else { return; } guard case .connected(_) = client.state else { let alert = UIAlertController.init(title: NSLocalizedString("Warning", comment: "alert title"), message: NSLocalizedString("Before changing roster you need to connect to server. Do you wish to do this now?", comment: "alert body"), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("No", comment: "button label"), style: .cancel, handler: {(alertAction) in _ = self.navigationController?.popViewController(animated: true); })); alert.addAction(UIAlertAction(title: NSLocalizedString("Yes", comment: "button label"), style: .default, handler: {(alertAction) in if var account = AccountManager.getAccount(for: self.account!) { account.active = true; try? AccountManager.save(account: account); } })); self.present(alert, animated: true, completion: nil); return; } let resultHandler = { (result: Result) in switch result { case .success(_): self.updateSubscriptions(client: client) DispatchQueue.main.async { self.dismissView(); } case .failure(let error): DispatchQueue.main.async { let alert = UIAlertController.init(title: NSLocalizedString("Failure", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Server returned an error: %@", comment: "alert body"), error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); self.present(alert, animated: true, completion: nil); } } }; if let rosterItem = DBRosterStore.instance.item(for: client, jid: jid!) { if rosterItem.name == nameTextField.text { updateSubscriptions(client: client); self.dismissView(); } else { client.module(.roster).updateItem(jid: jid!, name: nameTextField.text, groups: rosterItem.groups, completionHandler: resultHandler); } } else { client.module(.roster).addItem(jid: jid!, name: nameTextField.text, groups: [], completionHandler: resultHandler); } } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { return nil; } fileprivate func updateSubscriptions(client: XMPPClient) { guard let rosterItem = DBRosterStore.instance.item(for: client, jid: jid!) else { return; } let presenceModule = client.module(.presence); DispatchQueue.main.async { if self.receivePresenceUpdatesSwitch.isOn && !rosterItem.subscription.isTo { presenceModule.subscribe(to: self.jid!, preauth: self.preauth); } if !self.receivePresenceUpdatesSwitch.isOn && rosterItem.subscription.isTo { presenceModule.unsubscribe(from: self.jid!); } if self.sendPresenceUpdatesSwitch.isOn && !rosterItem.subscription.isFrom { presenceModule.subscribed(by: self.jid!); } if !self.sendPresenceUpdatesSwitch.isOn && rosterItem.subscription.isFrom { presenceModule.unsubscribed(by: self.jid!); } } } /* // MARK: - Navigation // In a storyboard-based application, you will often want to do a little preparation before navigation override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { // Get the new view controller using segue.destinationViewController. // Pass the selected object to the new view controller. } */ func numberOfComponents(in pickerView: UIPickerView) -> Int { return 1; } func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { return AccountManager.getAccounts().count; } func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { return AccountManager.getAccounts()[row].stringValue; } func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { self.accountTextField.text = self.pickerView(pickerView, titleForRow: row, forComponent: component); } } ================================================ FILE: SiskinIM/roster/RosterItemTableViewCell.swift ================================================ // // RosterItemTableViewCell.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class RosterItemTableViewCell: UITableViewCell { override var backgroundColor: UIColor? { get { return super.backgroundColor; } set { super.backgroundColor = newValue; avatarStatusView?.backgroundColor = newValue; } } @IBOutlet var avatarStatusView: AvatarStatusView! { didSet { self.avatarStatusView?.backgroundColor = self.backgroundColor; } } @IBOutlet var nameLabel: UILabel! @IBOutlet var statusLabel: UILabel! private var originalBackgroundColor: UIColor?; override func awakeFromNib() { super.awakeFromNib() // Initialization code } // override var isHighlighted: Bool { // didSet { // avatarStatusView?.backgroundColor = isHighlighted ? UIColor(named: "tintColor") : self.backgroundColor; // } // } override func setSelected(_ selected: Bool, animated: Bool) { if originalBackgroundColor == nil { originalBackgroundColor = self.backgroundColor; if originalBackgroundColor == nil { self.backgroundColor = UIColor.systemBackground; } } if animated { UIView.animate(withDuration: 0.2) { self.backgroundColor = selected ? UIColor.lightGray : self.originalBackgroundColor; } } else { self.backgroundColor = selected ? UIColor.lightGray : originalBackgroundColor; } } } ================================================ FILE: SiskinIM/roster/RosterProvider.swift ================================================ // // RosterProvider.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Shared import Martin import Combine protocol RosterProvider { func numberOfSections() -> Int; func numberOfRows(in section: Int) -> Int; func item(at indexPath: IndexPath) -> RosterProviderItem; func queryItems(contains: String?); func sectionHeader(at: Int) -> String?; func release(); } public enum RosterSortingOrder: String { case alphabetical case availability } public enum RosterType: String { case flat case grouped } public class RosterProviderAbstract { private let dispatcher = QueueDispatcher(label: "RosterProviderDispatcher"); internal weak var controller: AbstractRosterViewController?; @Published internal var allItems: [Item] = []; @Published internal var queryString: String? = nil; private var cancellables: Set = []; init(controller: AbstractRosterViewController) { self.controller = controller; DBRosterStore.instance.$items.combineLatest(Settings.$rosterAvailableOnly, PresenceStore.instance.$bestPresences, Settings.$rosterDisplayHiddenGroup).throttle(for: 0.1, scheduler: dispatcher.queue, latest: true).sink(receiveValue: { [weak self] items, available, presences, displayHidden in self?.updateItems(items: Array(items), presences: presences, available: available, displayHidden: displayHidden); }).store(in: &cancellables); self.$allItems.drop(while: { $0.isEmpty }).combineLatest(self.$queryString).map({ items, query in if let query = query, !query.isEmpty { return items.filter({ $0.displayName.lowercased().contains(query) || $0.jid.stringValue.lowercased().contains(query) }); } else { return items; } }).combineLatest(Settings.$rosterItemsOrder).receive(on: self.dispatcher.queue).sink(receiveValue: { [weak self] (items, order) in self?.updateItems(items: items, order: order) }).store(in: &cancellables); } func release() { controller = nil; cancellables.removeAll(); } func updateItems(items: [RosterItem], presences: [PresenceStore.Key: Presence], available: Bool, displayHidden: Bool) { var newItems = items.compactMap({ item -> Item? in guard let account = item.context?.userBareJid else { return nil; } guard !item.annotations.contains(where: { $0.type == "mix" }) else { return nil; } if !displayHidden { if item.groups.contains("Hidden") { return nil; } } return self.newItem(rosterItem: item, account: account, presence: presences[.init(account: account, jid: item.jid.bareJid)]); }); if available { newItems = newItems.filter({ $0.presence != nil }); } self.allItems = newItems; } func newItem(rosterItem item: RosterItem, account: BareJID, presence: Presence?) -> Item? { return nil; } func updateItems(items: [Item], order: RosterSortingOrder) { } func sort(items: [Item], order: RosterSortingOrder) -> [Item] { switch order { case .alphabetical: return items.sorted(by: { (i1, i2) in i1.displayName.lowercased() < i2.displayName.lowercased() }); case .availability: return items.sorted { (i1, i2) -> Bool in let s1 = i1.presence?.show?.weight ?? 0; let s2 = i2.presence?.show?.weight ?? 0; if s1 == s2 { return i1.displayName < i2.displayName; } return s1 > s2; }; } } // func findItemFor(account: BareJID, jid: JID) -> Item? { // if let idx = findItemIdxFor(account: account, jid: jid) { // return allItems[idx]; // } // return nil; // } // // func findItemIdxFor(account: BareJID, jid: JID) -> Int? { // let jidWithoutResource = JID(jid.bareJid); // return allItems.firstIndex { (item) -> Bool in // return item.account == account && (item.jid.resource != nil ? item.jid == jid : item.jid == jidWithoutResource) // } // } func queryItems(contains: String?) { self.queryString = contains?.lowercased(); } } public protocol RosterProviderItem: AnyObject { var account: BareJID { get } var jid: BareJID { get } var presence: Presence? { get } var displayName: String { get } } ================================================ FILE: SiskinIM/roster/RosterProviderFlat.swift ================================================ // // RosterProviderFlat.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Shared import Martin import Combine import UIKit public class RosterProviderFlat: RosterProviderAbstract, RosterProvider { private var items: [RosterProviderFlatItem] = []; private var cancellables: Set = []; private var initialized: Bool = false; override init(controller: AbstractRosterViewController) { self.items = []; super.init(controller: controller); } func numberOfSections() -> Int { return 1; } func numberOfRows(in section: Int) -> Int { return items.count; } func item(at indexPath: IndexPath) -> RosterProviderItem { return items[indexPath.row]; } func sectionHeader(at: Int) -> String? { return nil; } override func newItem(rosterItem item: RosterItem, account: BareJID, presence: Presence?) -> RosterProviderFlatItem? { return RosterProviderFlatItem(account: account, jid: item.jid.bareJid, presence: presence, displayName: item.name ?? item.jid.stringValue); } override func updateItems(items: [RosterProviderFlatItem], order: RosterSortingOrder) { let oldItems = self.items; let newItems = sort(items: items, order: order); let diff = newItems.calculateChanges(from: oldItems); DispatchQueue.main.sync { self.items = newItems; if !initialized { initialized = true; self.controller?.tableView.reloadData(); } else { self.controller?.tableView.beginUpdates(); self.controller?.tableView.deleteRows(at: diff.removed.map({ IndexPath(row: $0, section: 0) }), with: .fade); self.controller?.tableView.insertRows(at: diff.inserted.map({ IndexPath(row: $0, section: 0) }), with: .fade); self.controller?.tableView.endUpdates(); } } } func positionFor(item: RosterProviderItem) -> Int? { return items.firstIndex { $0.jid == item.jid && $0.account == item.account }; } } public class RosterProviderFlatItem: RosterProviderItem, Hashable { public static func == (lhs: RosterProviderFlatItem, rhs: RosterProviderFlatItem) -> Bool { return lhs.account == rhs.account && lhs.jid == rhs.jid && lhs.displayName == rhs.displayName; } public let account: BareJID; public let jid: BareJID; public let presence: Presence?; public let displayName: String; init(account: BareJID, jid: BareJID, presence: Presence?, displayName: String) { self.account = account; self.jid = jid; self.presence = presence; self.displayName = displayName; } public func hash(into hasher: inout Hasher) { hasher.combine(account); hasher.combine(jid); } } ================================================ FILE: SiskinIM/roster/RosterProviderGrouped.swift ================================================ // // RosterProviderGrouped.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Shared import Martin public class RosterProviderGrouped: RosterProviderAbstract, RosterProvider { private var groups = [RosterProviderGroup](); private var initialized = false; override init(controller: AbstractRosterViewController) { super.init(controller: controller); } func numberOfSections() -> Int { return groups.count; } func numberOfRows(in section: Int) -> Int { return groups[section].items.count; } func item(at indexPath: IndexPath) -> RosterProviderItem { return groups[indexPath.section].items[indexPath.row]; } func sectionHeader(at: Int) -> String? { return groups[at].name; } override func newItem(rosterItem item: RosterItem, account: BareJID, presence: Presence?) -> RosterProviderGroupedItem? { let groups = item.groups.isEmpty ? [NSLocalizedString("Default ", comment: "default roster group")] : item.groups; return RosterProviderGroupedItem(account: account, jid: item.jid.bareJid, presence: presence, displayName: item.name ?? item.jid.stringValue, groups: groups); } override func updateItems(items: [RosterProviderGroupedItem], order: RosterSortingOrder) { let groupNames = Set(items.flatMap({ $0.groups })).sorted(); let newGroups = groupNames.map({ name in RosterProviderGroup(name: name, items: self.sort(items: items.filter({ $0.groups.contains(name) }), order: order))}); let oldGroups = self.groups; let removeSections = IndexSet(oldGroups.map({ $0.name }).filter({ !groupNames.contains($0) }).compactMap({ name in oldGroups.firstIndex(where: { $0.name == name })})); let newSections = IndexSet(newGroups.map({ $0.name }).filter({ name in !oldGroups.contains(where: { $0.name == name })}).compactMap({ name in newGroups.firstIndex(where: { $0.name == name }) })); let rowChanges = calculateChanges(newGroups: newGroups, oldGroups: oldGroups); let rowRemovals = rowChanges.flatMap({ $0.removed }); let rowInserts = rowChanges.flatMap({ $0.inserted }); DispatchQueue.main.sync { self.groups = newGroups; if !self.initialized { self.initialized = true; self.controller?.tableView.reloadData(); } else { self.controller?.tableView.beginUpdates(); self.controller?.tableView.deleteSections(removeSections, with: .fade); self.controller?.tableView.deleteRows(at: rowRemovals, with: .fade); self.controller?.tableView.insertSections(newSections, with: .fade); self.controller?.tableView.insertRows(at: rowInserts, with: .fade); self.controller?.tableView.endUpdates(); } } } struct GroupChanges { let inserted: [IndexPath]; let removed: [IndexPath]; } private func calculateChanges(newGroups: [RosterProviderGroup], oldGroups: [RosterProviderGroup]) -> [GroupChanges] { var results: [GroupChanges] = []; for (newGroupIdx, newGroup) in newGroups.enumerated() { if let oldGroupIdx = oldGroups.firstIndex(where: { $0.name == newGroup.name }) { let oldGroup = oldGroups[oldGroupIdx]; let diff = newGroup.items.calculateChanges(from: oldGroup.items); results.append(GroupChanges(inserted: diff.inserted.map({ [newGroupIdx, $0] }), removed: diff.removed.map({ [oldGroupIdx, $0] }))); } } return results; } func positionsFor(item: RosterProviderGroupedItem) -> [IndexPath] { var paths = [IndexPath](); for section in 0.. Bool { return lhs.account == rhs.account && lhs.jid == rhs.jid && lhs.displayName == rhs.displayName; } public let account: BareJID; public let jid: BareJID; public let presence: Presence?; public let displayName: String; public let groups: [String]; init(account: BareJID, jid: BareJID, presence: Presence?, displayName: String, groups: [String]) { self.account = account; self.jid = jid; self.presence = presence; self.displayName = displayName; self.groups = groups; } public func hash(into hasher: inout Hasher) { hasher.combine(account); hasher.combine(jid); } } ================================================ FILE: SiskinIM/roster/RosterViewController.swift ================================================ // // RosterViewController.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine class RosterViewController: AbstractRosterViewController, UIGestureRecognizerDelegate { var availabilityFilterSelector: UISegmentedControl?; private var cancellables: Set = []; override func viewDidLoad() { super.viewDidLoad() searchController.searchBar.delegate = self; searchController.searchBar.scopeButtonTitles = [NSLocalizedString("By name", comment: "search bar scope"), NSLocalizedString("By status", comment: "search bar scope")]; availabilityFilterSelector = UISegmentedControl(items: [NSLocalizedString("All", comment: "filter scope"), NSLocalizedString("Available", comment: "filter scope")]); navigationItem.titleView = availabilityFilterSelector; if let selector = availabilityFilterSelector { Settings.$rosterAvailableOnly.map({ $0 ? 1 : 0 }).receive(on: DispatchQueue.main).assign(to: \.selectedSegmentIndex, on: selector).store(in: &cancellables); } availabilityFilterSelector?.addTarget(self, action: #selector(RosterViewController.availabilityFilterChanged), for: .valueChanged); Settings.$rosterItemsOrder.map({ $0 == .alphabetical ? 0 : 1 }).receive(on: DispatchQueue.main).assign(to: \.selectedScopeButtonIndex, on: searchController.searchBar).store(in: &cancellables); setColors(); updateNavBarColors(); } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); animate(); } private func animate() { guard let coordinator = self.transitionCoordinator else { return; } coordinator.animate(alongsideTransition: { [weak self] context in self?.setColors(); }, completion: nil); } private func setColors() { let appearance = UINavigationBarAppearance(); appearance.configureWithDefaultBackground(); appearance.backgroundColor = UIColor(named: "chatslistSemiBackground"); appearance.backgroundEffect = UIBlurEffect(style: .systemUltraThinMaterialDark); navigationController?.navigationBar.standardAppearance = appearance; navigationController?.navigationBar.scrollEdgeAppearance = appearance; searchController.searchBar.barStyle = .black; searchController.searchBar.tintColor = UIColor.white; navigationController?.navigationBar.barTintColor = UIColor(named: "chatslistBackground")?.withAlphaComponent(0.2); navigationController?.navigationBar.tintColor = UIColor.white; } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection); updateNavBarColors(); } func updateNavBarColors() { if self.traitCollection.userInterfaceStyle == .dark { availabilityFilterSelector?.setTitleTextAttributes([NSAttributedString.Key.foregroundColor : UIColor.white], for: .selected); availabilityFilterSelector?.setTitleTextAttributes([NSAttributedString.Key.foregroundColor : UIColor.white], for: .normal); searchController.searchBar.setScopeBarButtonTitleTextAttributes([NSAttributedString.Key.foregroundColor : UIColor.white], for: .selected) searchController.searchBar.setScopeBarButtonTitleTextAttributes([NSAttributedString.Key.foregroundColor : UIColor.white], for: .normal); } else { availabilityFilterSelector?.setTitleTextAttributes([NSAttributedString.Key.foregroundColor : UIColor(named: "chatslistBackground")!], for: .selected); availabilityFilterSelector?.setTitleTextAttributes([NSAttributedString.Key.foregroundColor : UIColor.white], for: .normal); searchController.searchBar.setScopeBarButtonTitleTextAttributes([NSAttributedString.Key.foregroundColor : UIColor(named: "chatslistBackground")!], for: .selected) searchController.searchBar.setScopeBarButtonTitleTextAttributes([NSAttributedString.Key.foregroundColor : UIColor.white], for: .normal); } searchController.searchBar.searchTextField.textColor = UIColor.white; searchController.searchBar.searchTextField.backgroundColor = (self.traitCollection.userInterfaceStyle != .dark ? UIColor.black : UIColor.white).withAlphaComponent(0.2); } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cellIdentifier = "RosterItemTableViewCell"; let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as! RosterItemTableViewCell; if let item = roster?.item(at: indexPath) { cell.nameLabel.text = item.displayName; cell.statusLabel.text = item.presence?.status ?? item.jid.stringValue; cell.avatarStatusView.displayableId = ContactManager.instance.contact(for: .init(account: item.account, jid: item.jid, type: .buddy)); } return cell; } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let item = roster?.item(at: indexPath) else { return; } createChat(for: item); } private func createChat(for item: RosterProviderItem) { if let conversation = DBChatStore.instance.conversation(for: item.account, with: item.jid) { open(conversation: conversation); } else { guard let client = XmppService.instance.getClient(for: item.account) else { return; } if let chat = client.module(.message).chatManager.createChat(for: client, with: item.jid) { open(conversation: chat as! Conversation); } } } private func open(conversation: Conversation) { var controller: UIViewController? = nil; switch conversation { case is Room: controller = UIStoryboard(name: "Groupchat", bundle: nil).instantiateViewController(withIdentifier: "RoomViewNavigationController"); case is Channel: controller = UIStoryboard(name: "MIX", bundle: nil).instantiateViewController(withIdentifier: "ChannelViewNavigationController"); default: controller = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "ChatViewNavigationController"); } let navigationController = controller as? UINavigationController; let destination = navigationController?.visibleViewController ?? controller; if let baseChatViewController = destination as? BaseChatViewController { baseChatViewController.conversation = conversation; } destination?.hidesBottomBarWhenPushed = true; if controller != nil { self.showDetailViewController(controller!, sender: self); } } func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { Settings.rosterItemsOrder = selectedScope == 0 ? .alphabetical : .availability; } @objc func availabilityFilterChanged(_ control: UISegmentedControl) { Settings.rosterAvailableOnly = control.selectedSegmentIndex == 1; } override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let item = roster?.item(at: indexPath) else { return nil; } return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { suggestedActions -> UIMenu? in return self.prepareContextMenu(item: item); }; } func prepareContextMenu(item: RosterProviderItem) -> UIMenu { var items = [ UIAction(title: NSLocalizedString("Chat", comment: "action label"), image: UIImage(systemName: "message"), handler: { action in self.createChat(for: item); }) ]; if CallManager.isAvailable { items.append(UIAction(title: NSLocalizedString("Video call", comment: "action label"), image: UIImage(named: "videoCall"), handler: { (action) in VideoCallController.call(jid: item.jid, from: item.account, media: [.audio, .video], sender: self); })); items.append(UIAction(title: NSLocalizedString("Audio call", comment: "action label"), image: UIImage(systemName: "phone"), handler: { (action) in VideoCallController.call(jid: item.jid, from: item.account, media: [.audio, .video], sender: self); })); } items.append(contentsOf: [ UIAction(title: NSLocalizedString("Edit", comment: "action label"), image: UIImage(systemName: "pencil"), handler: {(action) in self.openEditItem(for: item.account, jid: JID(item.jid)); }), UIAction(title: NSLocalizedString("Info", comment: "action label"), image: UIImage(systemName: "info.circle"), handler: { action in self.showItemInfo(for: item.account, jid: JID(item.jid)); }), UIAction(title: NSLocalizedString("Delete", comment: "action label"), image: UIImage(systemName: "trash"), attributes: .destructive, handler: { action in self.deleteItem(for: item.account, jid: JID(item.jid)); }) ]); return UIMenu(title: "", children: items); } @IBAction func addBtnClicked(_ sender: UIBarButtonItem) { self.openEditItem(for: nil, jid: nil); } func deleteItem(for account: BareJID, jid: JID) { if let rosterModule = XmppService.instance.getClient(for: account)?.module(.roster) { rosterModule.removeItem(jid: jid, completionHandler: { result in switch result { case .failure(let errorCondition): DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Failure", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Server returned an error: %@", comment: "alert body"), errorCondition.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); self.present(alert, animated: true, completion: nil); } case .success(_): break; } }) } } func openEditItem(for account: BareJID?, jid: JID?) { let navigationController = self.storyboard?.instantiateViewController(withIdentifier: "RosterItemEditNavigationController") as! UINavigationController; let itemEditController = navigationController.visibleViewController as? RosterItemEditViewController; itemEditController?.hidesBottomBarWhenPushed = true; itemEditController?.account = account; itemEditController?.jid = jid; navigationController.modalPresentationStyle = .formSheet; self.present(navigationController, animated: true, completion: nil); } func showItemInfo(for account: BareJID, jid: JID) { let navigation = storyboard?.instantiateViewController(withIdentifier: "ContactViewNavigationController") as! UINavigationController; let contactView = navigation.visibleViewController as! ContactViewController; contactView.hidesBottomBarWhenPushed = true; contactView.account = account; contactView.jid = jid.bareJid; navigation.title = self.navigationItem.title; navigation.modalPresentationStyle = .formSheet; self.present(navigation, animated: true, completion: nil); } } ================================================ FILE: SiskinIM/service/AvatarEventHandler.swift ================================================ // // AvatarEventHandler.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import Combine import os class AvatarEventHandler: XmppServiceExtension { static let instance = AvatarEventHandler(); private let queue = DispatchQueue(label: "AvatarEventHandler"); private init() { } func register(for client: XMPPClient, cancellables: inout Set) { client.module(.presence).presencePublisher.filter({ $0.presence.type != .error }).sink(receiveValue: { [weak client] e in guard let photoId = e.presence.vcardTempPhoto, let to = e.presence.to?.bareJid else { return; } self.queue.async { if e.presence.findChild(name: "x", xmlns: "http://jabber.org/protocol/muc#user") == nil { AvatarManager.instance.avatarHashChanged(for: e.jid.bareJid, on: to, type: .vcardTemp, hash: photoId); } else { os_log(OSLogType.debug, log: .avatar, "received presence from %s with avaar hash: %{public}s", e.presence.from!.stringValue, photoId); guard let client = client else { return; } if !AvatarManager.instance.hasAvatar(withHash: photoId) { os_log(OSLogType.debug, log: .avatar, "querying %s for VCard for avaar hash: %{public}s", e.presence.from!.stringValue, photoId); client.module(.vcardTemp).retrieveVCard(from: e.jid, completionHandler: { result in switch result { case .success(let vcard): os_log(OSLogType.debug, log: .avatar, "got result %s with %d photos from %s VCard for avaar hash: %{public}s", String(describing: type(of: vcard).self), vcard.photos.count, e.presence.from!.stringValue, photoId); vcard.photos.forEach({ photo in os_log(OSLogType.debug, log: .avatar, "got photo from %s VCard for avaar hash: %{public}s", e.presence.from!.stringValue, photoId); self.queue.async { AvatarManager.fetchData(photo: photo, completionHandler: { result in if let data = result { _ = AvatarManager.instance.storeAvatar(data: data); AvatarManager.instance.avatarUpdated(hash: photoId, for: e.jid.bareJid, on: to, withNickname: e.jid.resource); } }) } }) case .failure(let error): os_log(OSLogType.debug, log: .avatar, "got error %{public}s from %s VCard for avaar hash: %{public}s", error.description, e.presence.from!.stringValue, photoId); break; } }) } else { AvatarManager.instance.avatarUpdated(hash: photoId, for: e.jid.bareJid, on: to, withNickname: e.jid.resource); } } } }).store(in: &cancellables); client.module(.pepUserAvatar).avatarChangePublisher.sink(receiveValue: { [weak client] e in guard let account = client?.userBareJid else { return; } guard let item = e.info.first(where: { info -> Bool in return info.url == nil; }) else { return; } self.queue.async { AvatarManager.instance.avatarHashChanged(for: e.jid.bareJid, on: account, type: .pepUserAvatar, hash: item.id); } }).store(in: &cancellables); } } ================================================ FILE: SiskinIM/service/BlockedEventHandler.swift ================================================ // // BlockedEventHandler.swift // Siskin IM // // Created by Andrzej Wójcik on 24/11/2019. // Copyright © 2019 Tigase, Inc. All rights reserved. // import Foundation import Martin import Foundation import Martin import Combine class BlockedEventHandler: XmppServiceExtension { static let instance = BlockedEventHandler(); static func isBlocked(_ jid: JID, on client: Context) -> Bool { return client.module(.blockingCommand).blockedJids?.contains(jid) ?? false; } static func isBlocked(_ jid: JID, on account: BareJID) -> Bool { guard let client = XmppService.instance.getClient(for: account) else { return false; } return isBlocked(jid, on: client); } func register(for client: XMPPClient, cancellables: inout Set) { var prev: [JID] = []; client.module(.blockingCommand).$blockedJids.map({ $0 ?? []}).sink(receiveValue: { [weak client] blockedJids in guard let client = client else { return; } let prevSet = Set(prev); let blockedSet = Set(blockedJids); let changes = blockedJids.filter({ !prevSet.contains($0) }) + prev.filter({ !blockedSet.contains($0) }); prev = blockedJids; for jid in changes { var p = PresenceStore.instance.bestPresence(for: jid.bareJid, context: client); if p == nil { p = Presence(); p?.type = .unavailable; p?.from = jid; } ContactManager.instance.update(presence: p!, for: .init(account: client.userBareJid, jid: jid.bareJid, type: .buddy)) } }).store(in: &cancellables); } } ================================================ FILE: SiskinIM/service/DNSSrvDiskCache.swift ================================================ // // DnsSrvDiskCache.swift // // Siskin IM // Copyright (C) 2018 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import Combine open class DNSSrvDiskCache: DNSSrvResolverWithCache.DiskCache { private var cancellable: AnyCancellable?; public override init(cacheDirectoryName: String) { super.init(cacheDirectoryName: cacheDirectoryName); self.cancellable = AccountManager.accountEventsPublisher.sink(receiveValue: { event in switch event { case .disabled(let account), .removed(let account): self.store(for: account.name.domain, result: nil); case .enabled(_): break; } }); } @objc fileprivate func accountChanged(_ notification: Notification) { guard let account = notification.object as? AccountManager.Account else { return; } guard !(AccountManager.getAccount(for: account.name)?.active ?? false) else { return; } self.store(for: account.name.domain, result: nil); } } ================================================ FILE: SiskinIM/service/MeetEventHandler.swift ================================================ // // MeetEventHandler.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Combine import Martin import UIKit class MeetEventHandler: XmppServiceExtension { static let instance = MeetEventHandler(); private let queue = DispatchQueue(label: "MeetEventHandler"); @Published private(set) var supportedAccounts: [BareJID] = []; private init() { } func register(for client: XMPPClient, cancellables: inout Set) { client.module(.meet).eventsPublisher.sink(receiveValue: { [weak self] event in switch event { case .inivitation(let action, let sender): switch action { case .propose(let id, let meetJid, let media): let meet = Meet(client: client, jid: meetJid.bareJid, sid: id); guard let callManager = CallManager.instance else { return; } callManager.reportIncomingCall(meet, completionHandler: { result in switch result { case .success(_): break; case .failure(_): client.module(.meet).sendMessageInitiation(action: .reject(id: id), to: sender); break; } }); case .accept(let id): break; case .proceed(let id): break; case .retract(let id): CallManager.instance?.endCall(on: client.userBareJid, sid: id); case .reject(let id): CallManager.instance?.endCall(on: client.userBareJid, sid: id); } break; default: break; } }).store(in: &cancellables); client.module(.disco).$accountDiscoResult.receive(on: self.queue).sink(receiveValue: { [weak self] info in self?.supportedAccounts.removeAll(where: { $0 != client.userBareJid }); if !info.features.isEmpty { client.module(.meet).findMeetComponent(completionHandler: { result in self?.supportedAccounts.append(client.userBareJid); }) } }).store(in: &cancellables); } } ================================================ FILE: SiskinIM/service/MessageEventHandler.swift ================================================ // // MessageEventHandler.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import MartinOMEMO import os import Combine import TigaseLogging class MessageEventHandler: XmppServiceExtension { public static let instance = MessageEventHandler(); public static let eventsPublisher = PassthroughSubject(); private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MessageEventHandler"); enum SyncEvent { case started(account: BareJID, with: BareJID?) case finished(account: BareJID, with: BareJID?) } static func prepareBody(message: Message, forAccount account: BareJID, serverMsgId: String?) -> (String?, ConversationEntryEncryption) { var encryption: ConversationEntryEncryption = .none; guard (message.type ?? .chat) != .error else { guard let body = message.body else { if let delivery = message.messageDelivery { switch delivery { case .received(_): // if our message delivery confirmation is not delivered just drop this info return (nil, encryption); default: break; } } return (message.to?.resource == nil ? nil : "", encryption); } return (body, encryption); } var encryptionErrorBody: String?; if var from = message.from?.bareJid, let context = XmppService.instance.getClient(for: account) { if message.type == .groupchat, let nickname = message.from?.resource, let occupantJid = DBChatStore.instance.room(for: context, with: from)?.occupant(nickname: nickname)?.jid { from = occupantJid.bareJid; } // we need to know if MAM is being synced or not, if so, we should wait until it finishes! switch context.module(.omemo).decode(message: message, from: from, serverMsgId: serverMsgId) { case .successMessage(_, let keyFingerprint): encryption = .decrypted(fingerprint: keyFingerprint); break; case .successTransportKey(_, _): logger.debug("got transport key with key and iv!"); case .failure(let error): switch error { case .invalidMessage: encryptionErrorBody = NSLocalizedString("Message was not encrypted for this device.", comment: "message decryption error"); encryption = .notForThisDevice; case .duplicateMessage: // message is a duplicate and was processed before return (nil, .none); case .notEncrypted: encryption = .none; default: encryptionErrorBody = String.localizedStringWithFormat(NSLocalizedString("Message decryption failed! Error code: %d", comment: "message decryption error"), error.rawValue); encryption = .decryptionFailed(errorCode: error.rawValue); } break; } } guard let body = message.body ?? message.oob ?? encryptionErrorBody else { return (nil, encryption); } return (body, encryption); } private var cancellables: Set = []; init() { DBChatHistoryStore.instance.markedAsRead.filter({ !$0.onlyLocally }).sink(receiveValue: { [weak self] marked in self?.sendDisplayed(marked); }).store(in: &cancellables); MessageEventHandler.eventsPublisher.receive(on: MessageEventHandler.syncSinceQueue).sink(receiveValue: { [weak self] event in self?.syncStateChanged(event); }).store(in: &cancellables); } func register(for client: XMPPClient, cancellables: inout Set) { let account = client.userBareJid; client.$state.sink(receiveValue: { [weak client] state in guard case .connected(let resumed) = state, !resumed, let client = client else { return; } MessageEventHandler.scheduleMessageSync(for: client.userBareJid); DBChatHistoryStore.instance.loadUnsentMessage(for: client.userBareJid, completionHandler: { (account, messages) in DispatchQueue.global(qos: .background).async { for message in messages { var chat = DBChatStore.instance.conversation(for: account, with: message.jid); if chat == nil { switch DBChatStore.instance.createChat(for: client, with: message.jid) { case .created(let newChat): chat = newChat; case .found(let existingChat): chat = existingChat; case .none: return; } } if let dbChat = chat as? Chat { dbChat.resendMessage(content: message.data, isAttachment: message.type == .attachment, encryption: message.encryption != .none ? .omemo : .none, stanzaId: message.correctionStanzaId ?? message.stanzaId, correctedMessageOriginId: message.correctionStanzaId == nil ? nil : message.stanzaId); } } } }); }).store(in: &cancellables); client.module(.message).messagesPublisher.sink(receiveValue: { e in DBChatHistoryStore.instance.append(for: e.chat as! Chat, message: e.message, source: .stream); }).store(in: &cancellables); client.module(.messageDeliveryReceipts).receiptsPublisher.sink(receiveValue: { receipt in guard let conversation = MessageEventHandler.conversationKey(for: receipt.message, on: account) else { return; } DBChatHistoryStore.instance.updateItemState(for: conversation, stanzaId: receipt.messageId, from: .outgoing(.sent), to: .outgoing(.delivered)); }).store(in: &cancellables); client.context.module(.chatMarkers).markersPublisher.sink(receiveValue: { marker in guard let conversation = MessageEventHandler.conversationKey(for: marker.message, on: account), let sender = marker.message.from else { return; } let type = ChatMarker.MarkerType.from(chatMarkers: marker.marker); if let idx = sender.localPart?.firstIndex(of: "#"), let localPart = sender.localPart { let participantId = String(localPart[localPart.startIndex..= before }); } struct Item { let conversation: ConversationKey; let timestamp: Date; let stanzaId: String; } } private var readMarkersToSendQueue: [ReadMarkersKey: ReadMarkersQueue] = [:]; enum ReceiptType { case deliveryReceipt case chatMarker } func sendReceived(for conversation: ConversationKey, timestamp: Date, stanzaId: String, receipts: [ReceiptType]) { guard !receipts.isEmpty, let conv = (conversation as? Conversation) ?? DBChatStore.instance.conversation(for: conversation.account, with: conversation.jid) else { return; } if receipts.contains(.chatMarker) { if let queue = readMarkersToSendQueue[.init(account: conv.account, jid: conv.jid)] ?? readMarkersToSendQueue[.init(account: conv.account, jid: nil)] { queue.add(for: conv, timestamp: timestamp, stanzaId: stanzaId); } else { conv.sendChatMarker(.received(id: stanzaId), andDeliveryReceipt: receipts.contains(.deliveryReceipt)); } } else if receipts.contains(.deliveryReceipt) { conv.context?.module(.messageDeliveryReceipts).sendReceived(to: JID(conversation.jid), forStanzaId: stanzaId, type: .chat); } } func cancelReceived(for conv: ConversationKey, before: Date) { guard let queue = readMarkersToSendQueue[.init(account: conv.account, jid: conv.jid)] ?? readMarkersToSendQueue[.init(account: conv.account, jid: nil)] else { return; } queue.cancelReceived(for: conv, before: before); } private func syncStateChanged(_ event: SyncEvent) { switch event { case .started(let account, let jid): XmppService.instance.getClient(for: account)?.module(.omemo).mamSyncStarted(for: jid); readMarkersToSendQueue[.init(account: account, jid: jid)] = ReadMarkersQueue(); case .finished(let account, let jid): readMarkersToSendQueue.removeValue(forKey: .init(account: account, jid: jid))?.sendQueued(); XmppService.instance.getClient(for: account)?.module(.omemo).mamSyncFinished(for: jid); } } static func conversationKey(for message: Message, on account: BareJID) -> ConversationKey? { guard let from = message.from?.bareJid, let to = message.to?.bareJid else { return nil; } let jid = account != from ? from : to; return DBChatStore.instance.conversation(for: account, with: jid) ?? ConversationKeyItem(account: account, jid: jid); } static func calculateDirection(for conversation: ConversationKey, direction: MessageDirection, sender: ConversationEntrySender) -> MessageDirection { switch sender { case .none: assert(false, "Cannot calculate direction for sender `.none`") return .outgoing; case .me(_): return .outgoing; case .buddy(_): return .incoming; case .participant(let id, _, let jid): if let senderJid = jid { return senderJid == conversation.account ? .outgoing : .incoming; } if let channel = conversation as? Channel { return channel.participantId == id ? .outgoing : .incoming; } // we were not able to determine if we were senders or not. return direction; case .occupant(let nickname, let jid): if let senderJid = jid { return senderJid == conversation.account ? .outgoing : .incoming; } if let room = conversation as? Room { return room.nickname == nickname ? .outgoing : .incoming; } // we were not able to determine if we were senders or not. return direction; case .channel: return .incoming; } } static func calculateState(direction: MessageDirection, message: Message, isFromArchive archived: Bool, isMuc: Bool) -> ConversationEntryState { let error = message.type == StanzaType.error; let unread = (!archived) || isMuc; if direction == .incoming { if error { return .incoming_error(unread ? .received : .displayed, errorMessage: message.errorText ?? message.errorCondition?.rawValue); } return .incoming(unread ? .received : .displayed); } else { if error { return .outgoing_error(unread ? .received : .displayed,errorMessage: message.errorText ?? message.errorCondition?.rawValue); } return .outgoing(.sent); } } private static var syncSinceQueue = DispatchQueue(label: "syncSinceQueue"); private static var syncSince: [BareJID: Date] = [:]; static func scheduleMessageSync(for account: BareJID) { if let syncMessagesSince = DBChatHistoryStore.instance.lastMessageTimestamp(for: account) { // use last "received" stable stanza id for account MAM archive in case of MAM:2? syncSinceQueue.async { self.syncSince[account] = syncMessagesSince; } } } static func syncMessagesScheduled(for client: XMPPClient) { syncSinceQueue.async { let syncMessagesSince = syncSince.removeValue(forKey: client.userBareJid); syncMessages(for: client, since: syncMessagesSince); } } static func syncMessages(for client: XMPPClient, version: MessageArchiveManagementModule.Version? = nil, componentJID: JID? = nil, since: Date? = nil, rsmQuery: RSM.Query? = nil) { if let since = since { let period = DBChatHistorySyncStore.Period(account: client.userBareJid, component: componentJID?.bareJid, from: since, after: nil, to: nil); DBChatHistorySyncStore.instance.addSyncPeriod(period); } syncMessagePeriods(for: client, version: version, componentJID: componentJID?.bareJid) } static func syncMessagePeriods(for client: XMPPClient, version: MessageArchiveManagementModule.Version? = nil, componentJID jid: BareJID? = nil) { guard let first = DBChatHistorySyncStore.instance.loadSyncPeriods(forAccount: client.userBareJid, component: jid).first else { if jid != nil { DBChatMarkersStore.instance.syncCompleted(forAccount: client.userBareJid, with: jid!); } eventsPublisher.send(.finished(account: client.userBareJid, with: jid)); return; } eventsPublisher.send(.started(account: client.userBareJid, with: jid)); syncSinceQueue.async { syncMessages(for: client, period: first, version: version); } } static func syncMessages(for client: XMPPClient, period: DBChatHistorySyncStore.Period, version: MessageArchiveManagementModule.Version? = nil, rsmQuery: RSM.Query? = nil, retry: Int = 3) { let start = Date(); let queryId = UUID().uuidString; let account = client.userBareJid; client.module(.mam).queryItems(version: version, componentJid: period.component == nil ? nil : JID(period.component!), start: period.from, end: period.to, queryId: queryId, rsm: rsmQuery ?? RSM.Query(after: period.after, max: 150), completionHandler: { [weak client] result in switch result { case .success(let response): if response.complete || response.rsm == nil { DBChatHistorySyncStore.instance.removeSyncPerod(period); if let client = client { syncMessagePeriods(for: client, version: version, componentJID: period.component); } } else { if let last = response.rsm?.last, UUID(uuidString: last) != nil { DBChatHistorySyncStore.instance.updatePeriod(period, after: last); } DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) { guard let client = client else { return; } self.syncMessages(for: client, period: period, version: version, rsmQuery: response.rsm?.next(300)); } } os_log("for account %s fetch for component %s with id %s executed in %f s", log: .chatHistorySync, type: .debug, period.account.stringValue, period.component?.stringValue ?? "nil", queryId, Date().timeIntervalSince(start)); case .failure(let error): guard client?.state ?? .disconnected() == .connected(), retry > 0 && error != .feature_not_implemented else { os_log("for account %s fetch for component %s with id %s could not synchronize message archive for: %{public}s", log: .chatHistorySync, type: .debug, period.account.stringValue, period.component?.stringValue ?? "nil", queryId, error.description); if period.component != nil { DBChatMarkersStore.instance.syncCompleted(forAccount: account, with: period.component!); } eventsPublisher.send(.finished(account: period.account, with: period.component)) return; } DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) { guard let client = client else { return; } self.syncMessages(for: client, period: period, version: version, rsmQuery: rsmQuery, retry: retry - 1); } } }); } static func extractRealAuthor(from message: Message, for conversation: ConversationKey) -> (ConversationEntrySender,ConversationEntryRecipient)? { if message.type == .groupchat { if let mix = message.mix { if let id = message.from?.resource, let nickname = mix.nickname { return (.participant(id: id, nickname: nickname, jid: mix.jid), .none); } // invalid sender? what should we do? return nil; } else { // in this case it is most likely MUC groupchat message.. if let nickname = message.from?.resource { return (.occupant(nickname: nickname, jid: nil), .none); } // invalid sender? what should we do? return (.channel,.none); } } else { // this can be 1-1 message from MUC.. if let room = conversation as? Room, message.findChild(name: "x", xmlns: "http://jabber.org/protocol/muc#user") != nil { if conversation.account == message.from?.bareJid { // outgoing message! if let recipientNickname = message.to?.resource { return (.occupant(nickname: room.nickname, jid: nil), .occupant(nickname: recipientNickname)); } } else { // incoming message! if let senderNickname = message.from?.resource { return (.occupant(nickname: senderNickname, jid: nil), .occupant(nickname: room.nickname)); } } // invalid sender? what should we do? return nil; } } return (conversation.account == message.from?.bareJid ? .me(conversation: conversation) : .buddy(conversation: conversation), .none); } static func itemType(fromMessage message: Message) -> ItemType { if let oob = message.oob { if (message.body == nil || oob == message.body), URL(string: oob) != nil { return .attachment; } } return .message; } } ================================================ FILE: SiskinIM/service/MixEventHandler.swift ================================================ // // MixEventHandler.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import Combine class MixEventHandler: XmppServiceExtension { static let instance = MixEventHandler(); private init() { } func register(for client: XMPPClient, cancellables: inout Set) { client.$state.sink(receiveValue: { [weak client] state in guard let client = client, case .connected(let resumed) = state, !resumed else { return; } let disco = client.module(.disco); for channel in client.module(.mix).channelManager.channels(for: client) { disco.getItems(for: JID(channel.jid), node: "mix", completionHandler: { result in switch result { case .success(let info): (channel as! Channel).updateOptions({ options in options.features = Set(info.items.compactMap({ Channel.Feature.from(node: $0.node) })); }) case .failure(_): break; } }); } }).store(in: &cancellables); client.module(.mix).participantsEvents.sink(receiveValue: { event in guard case .joined(let participant) = event, let channel = participant.channel else { return; } let jid = participant.jid ?? BareJID(localPart: participant.id + "#" + channel.jid.localPart!, domain: channel.jid.domain); DBVCardStore.instance.vcard(for: jid, completionHandler: { vcard in guard vcard == nil else { return; } VCardManager.instance.refreshVCard(for: jid, on: channel.account, completionHandler: nil); }) }).store(in: &cancellables); client.module(.mix).messagesPublisher.sink(receiveValue: { e in DBChatHistoryStore.instance.append(for: e.channel as! Channel, message: e.message, source: .stream); }).store(in: &cancellables); } } ================================================ FILE: SiskinIM/service/MucEventHandler.swift ================================================ // // MucEventHandler.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import UserNotifications import Combine class MucEventHandler: XmppServiceExtension { static let instance = MucEventHandler(); func register(for client: XMPPClient, cancellables: inout Set) { client.$state.combineLatest(XmppService.instance.$isFetch).sink(receiveValue: { [weak client] state, isFetch in guard let client = client, case .connected(let resumed) = state, !resumed, !isFetch else { return; } client.module(.muc).roomManager.rooms(for: client).forEach { (room) in // first we need to check if room supports MAM DBChatMarkersStore.instance.awaitingSync(for: room as! Room); client.module(.disco).getInfo(for: JID(room.jid), completionHandler: { result in var mamVersions: [MessageArchiveManagementModule.Version] = []; switch result { case .success(let info): mamVersions = info.features.compactMap({ MessageArchiveManagementModule.Version(rawValue: $0) }); (room as! Room).roomFeatures = Set(info.features.compactMap({ Room.Feature(rawValue: $0) })); default: break; } if let timestamp = (room as? Room)?.timestamp { if !mamVersions.isEmpty { room.rejoin(fetchHistory: .skip).handle({ result in guard case .success(let r) = result else { return; } switch r { case .created(let room), .joined(let room): guard let client = room.context as? XMPPClient else { return; } MessageEventHandler.syncMessages(for: client, version: mamVersions.contains(.MAM2) ? .MAM2 : .MAM1, componentJID: JID(room.jid), since: timestamp); } }); } else { DBChatMarkersStore.instance.syncCompleted(forAccount: room.account, with: room.jid); _ = room.rejoin(fetchHistory: .from(timestamp)); } } else { DBChatMarkersStore.instance.syncCompleted(forAccount: room.account, with: room.jid); _ = room.rejoin(fetchHistory: .initial); } }); } }).store(in: &cancellables); client.module(.muc).messagesPublisher.sink(receiveValue: { e in let room = e.room as! Room; if let subject = e.message.subject { // how can we find room from here? room.subject = subject; } if let xUser = XMucUserElement.extract(from: e.message) { if xUser.statuses.contains(104) { self.updateRoomName(room: room); VCardManager.instance.refreshVCard(for: room.roomJid, on: room.account, completionHandler: nil); } } DBChatHistoryStore.instance.append(for: room, message: e.message, source: .stream); }).store(in: &cancellables); client.module(.muc).inivitationsPublisher.sink(receiveValue: { [weak client] invitation in guard let client = client, invitation.roomJid.localPart != nil else { return; } let mucModule = client.module(.muc); guard mucModule.roomManager.room(for: client, with: invitation.roomJid) == nil else { mucModule.decline(invitation: invitation, reason: nil); return; } InvitationManager.instance.addMucInvitation(for: client.userBareJid, roomJid: invitation.roomJid, invitation: invitation); }).store(in: &cancellables); client.module(.pepBookmarks).$currentBookmarks.drop(while: { it in !Settings.enableBookmarksSync }).sink(receiveValue: { [weak client] bookmarks in guard let client = client else { return; } let mucModule = client.module(.muc); bookmarks.items.compactMap({ $0 as? Bookmarks.Conference }).filter({ $0.autojoin }).filter { bookmark in return DBChatStore.instance.conversation(for: client.userBareJid, with: bookmark.jid.bareJid) == nil; }.forEach({ (bookmark) in guard let nick = bookmark.nick else { return; } _ = mucModule.join(roomName: bookmark.jid.localPart!, mucServer: bookmark.jid.domain, nickname: nick, password: bookmark.password); }); }).store(in: &cancellables); } static func showJoinError(_ err: XMPPError, for room: Room) { guard let error = MucModule.RoomError.from(error: err), let context = room.context else { return; } let content = UNMutableNotificationContent(); content.title = String.localizedStringWithFormat(NSLocalizedString("Room %@", comment: "alert title"), room.roomJid.stringValue); content.body = String.localizedStringWithFormat(NSLocalizedString("Could not join room. Reason:\n%@", comment: "alert body"), error.reason); content.sound = .default; if error != .banned && error != .registrationRequired { content.userInfo = ["account": context.userBareJid.stringValue, "roomJid": room.roomJid.stringValue, "nickname": room.nickname, "id": "room-join-error"]; } let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil); UNUserNotificationCenter.current().add(request) { (error) in } context.module(.muc).leave(room: room); } public func updateRoomName(room: Room) { room.context?.module(.disco).getInfo(for: JID(room.jid), completionHandler: { result in switch result { case .success(let info): let newName = info.identities.first(where: { (identity) -> Bool in return identity.category == "conference"; })?.name?.trimmingCharacters(in: .whitespacesAndNewlines); room.updateRoom(name: newName); case .failure(_): break; } }); } } class CustomMucModule: MucModule { override func join(room: RoomProtocol, fetchHistory: RoomHistoryFetch) -> Future { return Future({ promise in super.join(room: room, fetchHistory: fetchHistory).handle({ result in switch result { case .success(_): MucEventHandler.instance.updateRoomName(room: room as! Room); case .failure(_): break; } promise(result); }) }); } } extension MucModule.RoomError { var reason: String { switch self { case .banned: return NSLocalizedString("User is banned", comment: "muc error reason"); case .invalidPassword: return NSLocalizedString("Invalid password", comment: "muc error reason"); case .maxUsersExceeded: return NSLocalizedString("Maximum number of users exceeded", comment: "muc error reason"); case .nicknameConflict: return NSLocalizedString("Nickname already in use", comment: "muc error reason"); case .nicknameLockedDown: return NSLocalizedString("Nickname is locked down", comment: "muc error reason"); case .registrationRequired: return NSLocalizedString("Membership is required to access the room", comment: "muc error reason"); case .roomLocked: return NSLocalizedString("Room is locked", comment: "muc error reason"); } } } ================================================ FILE: SiskinIM/service/NewFeaturesDetector.swift ================================================ // // NewFeaturesDetector.swift // // Siskin IM // Copyright (C) 2018 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine enum ServerFeature: String { case mam case push public static func from(info: DiscoveryModule.DiscoveryInfoResult) -> [ServerFeature] { return from(features: info.features); } public static func from(features: [String]) -> [ServerFeature] { var serverFeatures: [ServerFeature] = []; if features.contains(MessageArchiveManagementModule.MAM_XMLNS) || features.contains(MessageArchiveManagementModule.MAM2_XMLNS) { serverFeatures.append(.mam); } if features.contains(PushNotificationsModule.PUSH_NOTIFICATIONS_XMLNS) { serverFeatures.append(.push); } return serverFeatures; } } class NewFeaturesDetector: XmppServiceExtension { public static let instance = NewFeaturesDetector(); private init() {} private struct QueueItem { let account: BareJID; let newFeatures: [ServerFeature]; let features: [ServerFeature]; } private let queue = DispatchQueue(label: "NewFeaturesDetector"); private var actionsQueue: [QueueItem] = []; private var inProgress: Bool = false; func register(for client: XMPPClient, cancellables: inout Set) { let account = client.userBareJid; client.module(.disco).$accountDiscoResult.receive(on: queue).filter({ !$0.features.isEmpty }).map({ ServerFeature.from(info: $0) }).sink(receiveValue: { [weak self] newFeatures in self?.newFeatures(newFeatures, for: account); }).store(in: &cancellables); client.module(.disco).$accountDiscoResult.receive(on: queue).filter({ $0.features.isEmpty && $0.identities.isEmpty }).sink(receiveValue: { [weak self] _ in self?.removeFeatures(for: account); }).store(in: &cancellables); } private func newFeatures(_ newFeatures: [ServerFeature], for account: BareJID) { let oldFeatures = AccountSettings.knownServerFeatures(for: account); let change = newFeatures.filter({ !oldFeatures.contains($0) }); guard !change.isEmpty else { return; } self.removeFeatures(for: account); actionsQueue.append(.init(account: account, newFeatures: change, features: newFeatures)); showNext(); } private func removeFeatures(for account: BareJID) { actionsQueue.removeAll(where: { $0.account == account}); } private var navController: UINavigationController?; func showNext(fromController: Bool = false) { DispatchQueue.main.async { guard UIApplication.shared.applicationState == .active else { self.navController?.dismiss(animated: true, completion: nil); self.navController = nil; return; } guard let item: QueueItem = self.queue.sync(execute: { guard !self.inProgress || fromController else { return nil; } let it = self.actionsQueue.first; if it != nil { self.actionsQueue.remove(at: 0); self.inProgress = true; } return it; }) else { self.navController?.dismiss(animated: true, completion: nil); self.navController = nil; return; } guard let client = XmppService.instance.getClient(for: item.account) else { self.queue.sync { self.inProgress = false; } self.showNext(); return; } let next: ()->Void = { if let navController = self.ensureNavController() { let controller = navController.visibleViewController as! SetAccountSettingsController; controller.client = client; if item.newFeatures.contains(.mam) { controller.sections.append(.mamEnable); controller.sections.append(.mamSyncInitial); } controller.completionHandler = { AccountSettings.knownServerFeatures(for: item.account, value: item.features); } controller.tableView.reloadData(); } else { self.queue.sync { self.inProgress = false; } } } if item.newFeatures.contains(.push) && Settings.enablePush == nil { self.showPushQuestion(completionHandler: item.newFeatures.count == 1 ? { self.showNext(fromController: true) } : next); } else { next(); } } } private func ensureNavController() -> UINavigationController? { guard let navController = self.navController else { let navController = UIStoryboard(name: "Account", bundle: nil).instantiateViewController(withIdentifier: "SetAccountSettingsNavController") as! UINavigationController; self.navController = navController; navController.modalPresentationStyle = .pageSheet; visibleController()?.present(navController, animated: true, completion: nil); return self.navController; } return navController; } private func visibleController() -> UIViewController? { let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }); if let controller = window?.rootViewController { return visibleController(parent: controller); } else { return nil; } } private func visibleController(parent: UIViewController) -> UIViewController { guard let presented = parent.presentedViewController else { return parent; } if let navController = presented as? UINavigationController, let visible = navController.visibleViewController { return visibleController(parent: visible); } if let tabController = presented as? UITabBarController, let visible = tabController.selectedViewController { return visibleController(parent: visible); } return presented; } private func showPushQuestion(completionHandler: @escaping ()->Void) { let alert = UIAlertController(title: NSLocalizedString("Push Notifications", comment: "alert title"), message: NSLocalizedString("If enabled, you will receive notifications of new messages or calls even if SiskinIM is in background. SiskinIM servers will forward those notifications for you from XMPP servers.", comment: "alert body"), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("Enable", comment: "button label"), style: .default, handler: { _ in Settings.enablePush = true; completionHandler(); })); alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: { _ in Settings.enablePush = false; completionHandler(); })) visibleController()?.present(alert, animated: true, completion: nil); } } ================================================ FILE: SiskinIM/service/PresenceRosterEventHandler.swift ================================================ // // PresenceRosterEventHandler.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import Combine class PresenceRosterEventHandler: XmppServiceExtension { public static let instance = PresenceRosterEventHandler(); private init() { } func register(for client: XMPPClient, cancellables: inout Set) { XmppService.instance.expectedStatus.sink(receiveValue: { [weak client] status in if let presenceModule = client?.module(.presence) { presenceModule.initialPresence = status.sendInitialPresence; if status.sendInitialPresence { presenceModule.setPresence(show: status.show, status: status.message, priority: nil); } } }).store(in: &cancellables); client.module(.presence).subscriptionPublisher.sink(receiveValue: { [weak client] change in guard let client = client else { return; } switch change.action { case .subscribe: InvitationManager.instance.addPresenceSubscribe(for: client.userBareJid, from: change.jid); default: break; } }).store(in: &cancellables); } } ================================================ FILE: SiskinIM/service/PushEventHandler.swift ================================================ // // PushEventHandler.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import Combine import TigaseLogging open class PushEventHandler: XmppServiceExtension { static let instance = PushEventHandler(); public static func unregisterDevice(from pushServiceJid: BareJID, account: BareJID, deviceId: String, completionHandler: @escaping (Result)->Void) { unregisterDevice(from: pushServiceJid, path: "", account: account, deviceId: deviceId, completionHandler: { result in switch result { case .success(_): completionHandler(.success(Void())); case .failure(let error): if error == .internal_server_error || error == .service_unavailable { self.unregisterDevice(from: pushServiceJid, path: "/rest/push", account: account, deviceId: deviceId, completionHandler: completionHandler); } else { completionHandler(.failure(error)); } } }) } private static func unregisterDevice(from pushServiceJid: BareJID, path: String, account: BareJID, deviceId: String, completionHandler: @escaping (Result)->Void) { guard let url = URL(string: "https://\(pushServiceJid.stringValue)\(path)/unregister-device/\(pushServiceJid.stringValue)") else { completionHandler(.failure(.service_unavailable)); return; } var request = URLRequest(url: url); request.httpMethod = "POST"; guard let payload = try? JSONEncoder().encode(UnregisterDeviceRequestPayload(account: account, provider: "tigase:messenger:apns:1", deviceToken: deviceId)) else { completionHandler(.failure(.internal_server_error)); return; } request.addValue("application/json", forHTTPHeaderField: "Content-Type"); request.httpBody = payload; let task = URLSession.shared.dataTask(with: request) { data, response, error in guard error == nil else { completionHandler(.failure(.service_unavailable)); return; } guard let data = data, let payload = try? JSONDecoder().decode(UnregisterDeviceResponsePayload.self, from: data) else { completionHandler(.failure(.internal_server_error)); return; } if payload.success { completionHandler(.success(Void())); } else { completionHandler(.failure(.not_acceptable)); } } task.resume(); } var deviceId: String?; var pushkitDeviceId: String?; private var cancellables: Set = []; private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PushEventHandler"); public func register(for client: XMPPClient, cancellables: inout Set) { Settings.$enablePush.map({ $0 ?? false }).combineLatest(client.module(.disco).$accountDiscoResult).sink(receiveValue: { [weak client, weak self] enable, features in guard let client = client, client.state == .connected() else { return; } self?.updatePushRegistration(for: client, features: features.features, shouldEnable: enable); }).store(in: &cancellables); } init() { DBChatStore.instance.conversationsEventsPublisher.sink(receiveValue: { [weak self] event in switch event { case .destroyed(let conversation): self?.conversationDestroyed(conversation); case .created(let conversation): break; } }).store(in: &cancellables); } func updatePushRegistration(for client: XMPPClient, features: [String], shouldEnable: Bool) { guard let deviceId = self.deviceId else { return; } let pushModule = client.module(.push) as! SiskinPushNotificationsModule; let hasPush = features.contains(SiskinPushNotificationsModule.PUSH_NOTIFICATIONS_XMLNS); let hasPushJingle = features.contains(TigasePushNotificationsModule.Jingle.XMLNS); let pushkitDeviceId = hasPushJingle ? self.pushkitDeviceId : nil; if hasPush && shouldEnable { if let pushSettings = pushModule.pushSettings { if pushSettings.deviceId != deviceId || pushSettings.pushkitDeviceId != pushkitDeviceId { pushModule.unregisterDeviceAndDisable(completionHandler: { result in switch result { case .success(_): pushModule.registerDeviceAndEnable(deviceId: deviceId, pushkitDeviceId: pushkitDeviceId, completionHandler: { result2 in self.logger.debug("reregistration for account: \(client.userBareJid), result: \(result2)"); }); case .failure(_): // we need to try again later break; } }); return; } else if AccountSettings.pushHash(for: client.userBareJid) == 0 { pushModule.reenable(pushSettings: pushSettings, completionHandler: { result in self.logger.debug("reenabling device for account: \(client.userBareJid), result: \(result)"); }) } } else { pushModule.registerDeviceAndEnable(deviceId: deviceId, pushkitDeviceId: pushkitDeviceId, completionHandler: { result in self.logger.debug("automatic registration for account: \(client.userBareJid), result: \(result)"); }) } } else { if pushModule.pushSettings != nil, (!hasPush) || (!shouldEnable) { pushModule.unregisterDeviceAndDisable(completionHandler: { result in self.logger.debug("automatic deregistration for account: \(client.userBareJid), result: \(result)"); }) } } } private func conversationDestroyed(_ c: Conversation) { switch c { case is Chat: // nothing to do for now... break; case let room as Room: guard room.options.notifications != .none else { return; } DispatchQueue.global(qos: .background).async { self.updateAccountPushSettings(for: room.account); } case let channel as Channel: guard channel.options.notifications != .none else { return; } DispatchQueue.global(qos: .background).async { self.updateAccountPushSettings(for: channel.account); } default: break; } } func updateAccountPushSettings(for account: BareJID) { guard AccountSettings.pushHash(for: account) != 0 else { return; } if let client = XmppService.instance.getClient(for: account), client.state == .connected(), let pushModule = client.module(.push) as? SiskinPushNotificationsModule, let pushSettings = pushModule.pushSettings { pushModule.reenable(pushSettings: pushSettings, completionHandler: { result in self.logger.debug("updating account push settings finished for account: \(client.userBareJid)"); }) } else { AccountSettings.pushHash(for: account, value: 0); } } public struct UnregisterDeviceRequestPayload: Encodable { var account: BareJID; var provider: String; var deviceToken: String; enum CodingKeys: String, CodingKey { case account case provider case deviceToken = "device-token" } } public struct UnregisterDeviceResponsePayload: Decodable { var success: Bool; public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self); success = try "success" == container.decode(String.self, forKey: .result); } enum CodingKeys: String, CodingKey { case result = "result"; } } } ================================================ FILE: SiskinIM/service/StreamFeaturesCache.swift ================================================ // // StreamFeaturesCache.swift // // Siskin IM // Copyright (C) 2018 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin class StreamFeaturesCache: StreamFeaturesModuleWithPipeliningCacheProtocol { fileprivate static let CACHED_STREAM_FEATURES = "cachedStreamFeatures"; fileprivate let fileManager: FileManager; fileprivate let path: String; public init(cacheDirectoryName: String = "stream-features-cache") { fileManager = FileManager.default; let url = try! fileManager.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true); path = url.appendingPathComponent(cacheDirectoryName, isDirectory: true).path; createDirectory(); } fileprivate func createDirectory() { guard !fileManager.fileExists(atPath: path) else { return; } try! fileManager.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil); } func getFeatures(for context: Context, embeddedStreamNo: Int) -> Element? { // TODO: Add caching if needed // if let cached: [Element] = sessionObject.getProperty(StreamFeaturesCache.CACHED_STREAM_FEATURES) { // if cached.count > embeddedStreamNo { // return cached[embeddedStreamNo]; // } else { // return nil; // } // } guard embeddedStreamNo == 0 else { return nil; } let filePath = path + "/" + context.userBareJid.domain; guard fileManager.fileExists(atPath: filePath) else { return nil; } let fileUrl = URL(fileURLWithPath: filePath); guard let data = try? String(contentsOf: fileUrl, encoding: .utf8) else { return nil; } if let cached = Element.from(string: data)?.getChildren() { // sessionObject.setProperty(StreamFeaturesCache.CACHED_STREAM_FEATURES, value: cached); if cached.count > embeddedStreamNo { return cached[embeddedStreamNo]; } else { return nil; } } return nil; } func set(for context: Context, features: [Element]?) { let filePath = path + "/" + context.userBareJid.domain; if features == nil { try? fileManager.removeItem(atPath: filePath); } else { try? Element(name: "cache", children: features!).stringValue.write(toFile: filePath, atomically: false, encoding: .utf8); } } } ================================================ FILE: SiskinIM/service/XMPPClient_extension.swift ================================================ // // XMPPClient_extension.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin extension XMPPClient { fileprivate static let RETRY_NO_KEY = "retryNo"; var retryNo: Int { get { return sessionObject.getProperty(XMPPClient.RETRY_NO_KEY) ?? 0; } set { sessionObject.setUserProperty(XMPPClient.RETRY_NO_KEY, value: newValue); } } } extension SocketConnectorNetwork.Endpoint: Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self); self.init(proto: ConnectorProtocol(rawValue: try container.decode(String.self, forKey: .proto))!, host: try container.decode(String.self, forKey: .host), port: try container.decode(Int.self, forKey: .port)); } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self); try container.encode(proto.rawValue, forKey: .proto); try container.encode(host, forKey: .host); try container.encode(port, forKey: .port); } public enum CodingKeys: String, CodingKey { case proto case host case port } } ================================================ FILE: SiskinIM/service/XmppService.swift ================================================ // // XmppService.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Shared import Martin import MartinOMEMO import Combine import Shared import TigaseLogging extension Presence.Show: Codable { } extension XMPPClient: Hashable { public static func == (lhs: XMPPClient, rhs: XMPPClient) -> Bool { return lhs.connectionConfiguration.userJid == rhs.connectionConfiguration.userJid; } public func hash(into hasher: inout Hasher) { hasher.combine(connectionConfiguration.userJid); } } open class XmppService { public static let SERVER_CERTIFICATE_ERROR = Notification.Name("serverCertificateError"); public static let AUTHENTICATION_ERROR = Notification.Name("authenticationFailure"); open var fetchTimeShort: TimeInterval = 5; open var fetchTimeLong: TimeInterval = 20; public static let pushServiceJid = JID("push.tigase.im"); public static let instance = XmppService(); public let tasksQueue = KeyedTasksQueue(); fileprivate var creationDate = NSDate(); fileprivate var fetchClientsWaitingForReconnection: [BareJID] = []; fileprivate var fetchStart = NSDate(); let extensions: [XmppServiceExtension] = [MessageEventHandler.instance, BlockedEventHandler.instance, PresenceRosterEventHandler.instance, AvatarEventHandler.instance, MixEventHandler.instance, MucEventHandler.instance, NewFeaturesDetector.instance, PushEventHandler.instance, MeetEventHandler.instance]; @Published open private(set) var applicationState: ApplicationState = .suspended; open var onCall: Bool = false { didSet { // FIXME: handle this properly!! } } @Published public private(set) var clients: [BareJID: XMPPClient] = [:]; private func client(for account: BareJID) -> XMPPClient? { return clients[account]; } fileprivate let dispatcher: QueueDispatcher = QueueDispatcher(label: "xmpp_service"); @Published var status: Status = Status(show: nil, message: nil, shouldConnect: true, sendInitialPresence: false); public let expectedStatus = CurrentValueSubject(Status(show: nil, message: nil, shouldConnect: false, sendInitialPresence: false)); @Published fileprivate(set) var currentStatus: Status = Status(show: nil, message: nil, shouldConnect: false, sendInitialPresence: false); @Published public private(set) var connectedClients: Set = []; private var cancellables: Set = []; private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "XmppService"); fileprivate let dnsSrvResolverCache: DNSSrvResolverCache; fileprivate let dnsSrvResolver: DNSSrvResolver; fileprivate let streamFeaturesCache: StreamFeaturesCache; init() { self.dnsSrvResolverCache = DNSSrvResolverWithCache.InMemoryCache(store: DNSSrvDiskCache(cacheDirectoryName: "dns-cache")); self.dnsSrvResolver = DNSSrvResolverWithCache(resolver: XMPPDNSSrvResolver(directTlsEnabled: true), cache: self.dnsSrvResolverCache); self.streamFeaturesCache = StreamFeaturesCache(); //self.applicationState = UIApplication.shared.applicationState == .active ? .active : .inactive; Settings.$statusType.combineLatest(Settings.$statusMessage, { type, messsage in Status(show: type, message: (messsage?.isEmpty ?? true) ? nil : messsage, shouldConnect: true, sendInitialPresence: true); }).assign(to: \.status, on: self).store(in: &cancellables); expectedStatus.map({ status in return status.shouldConnect }).removeDuplicates().sink(receiveValue: { [weak self] available in if available { self?.connectClients(ignoreCheck: true); } else { self?.disconnectClients(force: !NetworkMonitor.shared.isNetworkAvailable); } }).store(in: &cancellables); expectedStatus.combineLatest($connectedClients.map({ !$0.isEmpty })).map({ status, connected in if !connected { return status.with(show: nil); } return status; }).sink(receiveValue: { [weak self] status in self?.currentStatus = status }).store(in: &cancellables); AccountManager.accountEventsPublisher.receive(on: self.dispatcher.queue).sink(receiveValue: { [weak self] event in self?.accountChanged(event: event); }).store(in: &cancellables); } private func accountChanged(event: AccountManager.Event) { switch event { case .enabled(let account, let reconnect): guard reconnect else { return; } AccountSettings.reconnectionLocation(for: account.name, value: nil); if let client = self.client(for: account.name) { // if client exists and is connected, then reconnect it.. if client.state != .disconnected() { _ = client.disconnect(); } } else { let client = self.initializeClient(for: account); _ = self.register(client: client, for: account); self.connect(client: client, for: account); } case .disabled(let account), .removed(let account): if let client = self.client(for: account.name) { let prevState = client.state; _ = client.disconnect(); if prevState == .disconnected() && client.state == .disconnected() { self.unregisterClient(client); } } self.dnsSrvResolverCache.store(for: account.name.domain, result: nil); } } open func initialize() { for account in AccountManager.getActiveAccounts() { let client = self.initializeClient(for: account); _ = self.register(client: client, for: account); } self.$status.combineLatest($applicationState, NetworkMonitor.shared.$isNetworkAvailable, self.$isFetch, { (status, appState, networkAvailble, isFetch) -> Status in var newStatus = status; if status.show == nil && appState != .suspended { newStatus = status.with(show: appState == .inactive ? .xa : .online); } return newStatus.with(shouldConnect: networkAvailble && ((appState != .suspended) || isFetch), sendInitialPresence: appState != .suspended); }).assign(to: \.value, on: expectedStatus).store(in: &cancellables); } open func updateApplicationState(_ state: ApplicationState) { //dispatcher.async { switch state { case .active, .inactive: self.applicationState = state; case .suspended: guard self.applicationState == .inactive else { return; } self.applicationState = state; } //} } open func getClient(for account:BareJID) -> XMPPClient? { return dispatcher.sync { return self.client(for: account); } } private func connectClients(ignoreCheck: Bool) { dispatcher.async { self.clients.values.forEach { client in self.reconnect(client: client, ignoreCheck: ignoreCheck); } } } private func disconnectClients(force: Bool = false) { dispatcher.async { self.clients.values.forEach { client in _ = client.disconnect(force); } } } fileprivate func sendKeepAlive() { dispatcher.async { self.clients.values.forEach { client in client.keepalive(); } } } private func reconnect(client: XMPPClient, ignoreCheck: Bool = false) { self.dispatcher.sync { guard client.state == .disconnected(), let account = AccountManager.getAccount(for: client.userBareJid), account.active, ignoreCheck || self.expectedStatus.value.shouldConnect else { return; } self.connect(client: client, for: account); } } private func connect(client: XMPPClient, for account: AccountManager.Account) { client.connectionConfiguration.credentials = .password(password: account.password ?? "", authenticationName: nil, cache: nil); client.connectionConfiguration.modifyConnectorOptions(type: SocketConnectorNetwork.Options.self, { options in if let serverCertificate = account.serverCertificate, serverCertificate.accepted { options.sslCertificateValidation = .fingerprint(serverCertificate.details.fingerprintSha1); } else { options.sslCertificateValidation = .default; } options.connectionDetails = account.endpoint; if let idx = options.networkProcessorProviders.firstIndex(where: { $0 is SSLProcessorProvider }) { options.networkProcessorProviders.remove(at: idx); } options.networkProcessorProviders.append(account.disableTLS13 ? SSLProcessorProvider(supportedTlsVersions: TLSVersion.TLSv1_2...TLSVersion.TLSv1_2) : SSLProcessorProvider()); }); client.connectionConfiguration.resource = UIDevice.current.name; // switch account.resourceType { // case .automatic: // client.connectionConfiguration.resource = nil; // case .hostname: // client.connectionConfiguration.resource = Host.current().localizedName; // case .custom: // let val = account.resourceName; // client.connectionConfiguration.resource = (val == nil || val!.isEmpty) ? nil : val; // } if let pushModule = client.module(.push) as? SiskinPushNotificationsModule { pushModule.pushSettings = account.pushSettings; } // for push notifications this needs to be far lower value, ie. 60-90 seconds client.modulesManager.module(.streamManagement).maxResumptionTimeout = account.pushNotifications ? 90 : 3600; if let streamFeaturesModule: StreamFeaturesModuleWithPipelining = client.modulesManager.moduleOrNil(.streamFeatures) as? StreamFeaturesModuleWithPipelining { streamFeaturesModule.enabled = Settings.xmppPipelining; } let connectorEndpoint: ConnectorEndpoint? = AccountSettings.reconnectionLocation(for: account.name); client.login(lastSeeOtherHost: connectorEndpoint); } private class ClientCancellables { var cancellables: Set = []; } private var clientCancellables: [BareJID:ClientCancellables] = [:]; private func disconnected(client: XMPPClient) { let accountName = client.sessionObject.userBareJid!; defer { DBChatStore.instance.resetChatStates(for: accountName); } self.dispatcher.sync { let active = AccountManager.getAccount(for: accountName)?.active if !(active ?? false) { self.unregisterClient(client, removed: active == nil); } } guard self.expectedStatus.value.shouldConnect else { return; } let retry = client.retryNo; client.retryNo = retry + 1; var timeout = 2.0 * Double(retry) + 0.5; if timeout > 16 { timeout = 15; } DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + timeout) { [weak client] in if let c = client { self.reconnect(client: c); } } } private func unregisterClient(_ client: XMPPClient, removed: Bool = false) { dispatcher.sync { let accountName = client.sessionObject.userBareJid!; guard let client = self.clients.removeValue(forKey: accountName) else { return; } self.clientCancellables.removeValue(forKey: accountName); dispatcher.async { if removed { DBRosterStore.instance.clear(for: client) DBChatStore.instance.closeAll(for: accountName); DBChatHistoryStore.instance.removeHistory(for: accountName, with: nil); _ = client; } } } } func forEachClient(_ task: @escaping (XMPPClient)->Void) { let clients = dispatcher.sync { return Array(self.clients.values); } clients.forEach(task); } open func backgroundTaskFinished() { // guard applicationState != .active else { // return; // } let delegate = UIApplication.shared.delegate as? AppDelegate; let unsent = DBChatHistoryStore.instance.countUnsentMessages(); if unsent > 0 { delegate?.notifyUnsentMessages(count: unsent); } } private class FetchState { private var accountsInProgress: Set; private var completionHandler: (()->Void); var cancellables: Set = []; private let queue = DispatchQueue(label: "FetchStateQueue"); init(accountsInProgress: Set, completionHandler: @escaping ()->Void) { self.accountsInProgress = accountsInProgress; self.completionHandler = completionHandler; } func completed(for account: BareJID) { queue.sync { guard accountsInProgress.remove(account) != nil, accountsInProgress.isEmpty else { return; } completionHandler(); } } func expired() { queue.sync { guard !accountsInProgress.isEmpty else { return; } completionHandler(); } } } private var fetchState: FetchState?; @Published private(set) var isFetch: Bool = false; open func preformFetch(completionHandler: @escaping (UIBackgroundFetchResult)->Void) { guard applicationState != .active else { logger.debug("skipping background fetch as application is active"); completionHandler(.newData); return; } guard NetworkMonitor.shared.isNetworkAvailable == true else { logger.debug("skipping background fetch as network is not available"); completionHandler(.failed); return; } let clients = self.clients.values.filter({ !$0.isConnected }); guard !clients.isEmpty else { completionHandler(.noData); return; } let fetchState = FetchState(accountsInProgress: Set(clients.map({ $0.userBareJid })), completionHandler: { self.fetchState = nil; self.isFetch = false; completionHandler(.newData); }); self.fetchState = fetchState; MessageEventHandler.eventsPublisher.compactMap({ event in guard case .finished(let account, let jid) = event, jid == nil else { return nil; } return account; }).sink(receiveValue: { [weak fetchState] account in fetchState?.completed(for: account); }).store(in: &fetchState.cancellables); isFetch = true; } open func performFetchExpired() { self.fetchState?.expired(); } fileprivate func initializeClient(for account: AccountManager.Account) -> XMPPClient { let jid = account.name; let client = XMPPClient(); client.connectionConfiguration.modifyConnectorOptions(type: SocketConnectorNetwork.Options.self, { options in options.dnsResolver = self.dnsSrvResolver; options.networkProcessorProviders.append(SSLProcessorProvider()); options.connectionTimeout = 15; }) client.connectionConfiguration.userJid = jid; _ = client.modulesManager.register(AuthModule()); _ = client.modulesManager.register(StreamFeaturesModuleWithPipelining(cache: streamFeaturesCache, enabled: false)); _ = client.modulesManager.register(StreamManagementModule()); _ = client.modulesManager.register(SaslModule()); // if you do not want Pipelining you may use StreamFeaturesModule instead StreamFeaturesModuleWithPipelining //_ = client.modulesManager.register(StreamFeaturesModule()); _ = client.modulesManager.register(ResourceBinderModule()); _ = client.modulesManager.register(SessionEstablishmentModule()); _ = client.modulesManager.register(DiscoveryModule(identity: DiscoveryModule.Identity(category: "client", type: "pc", name: (Bundle.main.infoDictionary!["CFBundleName"] as! String)))); _ = client.modulesManager.register(SoftwareVersionModule(version: SoftwareVersionModule.SoftwareVersion(name: Bundle.main.infoDictionary!["CFBundleName"] as! String, version: Bundle.main.infoDictionary!["CFBundleVersion"] as! String, os: UIDevice.current.systemName))); _ = client.modulesManager.register(RosterModule(rosterManager: RosterManagerBase(store: DBRosterStore.instance))); _ = client.modulesManager.register(VCardTempModule()); _ = client.modulesManager.register(VCard4Module()); _ = client.modulesManager.register(PingModule()); _ = client.modulesManager.register(ClientStateIndicationModule()); _ = client.modulesManager.register(MobileModeModule()); _ = client.modulesManager.register(BlockingCommandModule()); _ = client.modulesManager.register(PubSubModule()); _ = client.modulesManager.register(PEPUserAvatarModule()); _ = client.modulesManager.register(PEPBookmarksModule()); _ = client.modulesManager.register(HttpFileUploadModule()); let messageModule = MessageModule(chatManager: ChatManagerBase(store: DBChatStore.instance)); _ = client.modulesManager.register(messageModule); _ = client.modulesManager.register(MessageCarbonsModule()); _ = client.modulesManager.register(MessageArchiveManagementModule()); _ = client.modulesManager.register(MessageDeliveryReceiptsModule()).sendReceived = false; _ = client.modulesManager.register(ChatMarkersModule()); _ = client.modulesManager.register(PresenceModule(store: PresenceStore.instance)); client.modulesManager.register(CapabilitiesModule(cache: DBCapabilitiesCache.instance, additionalFeatures: [.lastMessageCorrection, .messageRetraction])); client.modulesManager.register(CustomMucModule(roomManager: RoomManagerBase(store: DBChatStore.instance))); client.modulesManager.register(MixModule(channelManager: ChannelManagerBase(store: DBChatStore.instance))); _ = client.modulesManager.register(AdHocCommandsModule()); _ = client.modulesManager.register(SiskinPushNotificationsModule(defaultPushServiceJid: XmppService.pushServiceJid, provider: SiskinPushNotificationsModuleProvider())); let jingleModule = client.modulesManager.register(JingleModule(sessionManager: JingleManager.instance)); jingleModule.register(transport: Jingle.Transport.ICEUDPTransport.self, features: [Jingle.Transport.ICEUDPTransport.XMLNS, "urn:xmpp:jingle:apps:dtls:0"]); jingleModule.register(description: Jingle.RTP.Description.self, features: ["urn:xmpp:jingle:apps:rtp:1", "urn:xmpp:jingle:apps:rtp:audio", "urn:xmpp:jingle:apps:rtp:video"]); jingleModule.supportsMessageInitiation = true; _ = client.modulesManager.register(ExternalServiceDiscoveryModule()); client.modulesManager.register(MeetModule()); _ = client.modulesManager.register(InBandRegistrationModule()); // TODO: restore support for caching salted password // ScramMechanism.setSaltedPasswordCache(AccountManager.saltedPasswordCache, sessionObject: client.sessionObject); let signalStorage = OMEMOStoreWrapper(context: client.context); let signalContext = SignalContext(withStorage: signalStorage)!; signalStorage.setup(withContext: signalContext); _ = client.modulesManager.register(OMEMOModule(aesGCMEngine: OpenSSL_AES_GCM_Engine(), signalContext: signalContext, signalStorage: signalStorage)); return client; } fileprivate func register(client: XMPPClient, for account: AccountManager.Account) -> XMPPClient { return self.dispatcher.sync { let clientCancellables = ClientCancellables(); self.clientCancellables[account.name] = clientCancellables; client.$state.subscribe(account.state).store(in: &clientCancellables.cancellables); client.$state.dropFirst().sink(receiveValue: { state in self.changedState(state, for: client) }).store(in: &clientCancellables.cancellables); for ext in extensions { ext.register(for: client, cancellables: &clientCancellables.cancellables); } client.$state.combineLatest($applicationState).sink(receiveValue: { [weak client] (clientState, applicationState) in if clientState == .connected() { _ = client?.module(.csi).setState(applicationState == .active); } }).store(in: &clientCancellables.cancellables); self.clients[account.name] = client; return client; } } private func changedState(_ state: XMPPClient.State, for client: XMPPClient) { switch state { case .connected: self.dispatcher.async { self.connectedClients.insert(client); } case .disconnected(let reason): self.dispatcher.async { self.connectedClients.remove(client); } AccountSettings.reconnectionLocation(for: client.userBareJid, value: nil); switch reason { case .sslCertError(let trust): let certData = ServerCertificateInfo(trust: trust); if var account = AccountManager.getAccount(for: client.userBareJid) { account.active = false; account.serverCertificate = certData; try? AccountManager.save(account: account); NotificationCenter.default.post(name: XmppService.SERVER_CERTIFICATE_ERROR, object: client.userBareJid, userInfo: ["account": client.userBareJid.stringValue, "cert-name": certData.details.name, "cert-hash-sha1": certData.details.fingerprintSha1, "issuer-name": certData.issuer?.name, "issuer-hash-sha1": certData.issuer?.fingerprintSha1]); } case .authenticationFailure(let err): if let error = err as? SaslError { switch error { case .aborted, .temporary_auth_failure: // those are temporary errors, we shoud retry break; default: reportSaslError(on: client.userBareJid, error: error); } } else { reportSaslError(on: client.userBareJid, error: .not_authorized); } case .none: AccountSettings.reconnectionLocation(for: client.userBareJid, value: client.connector?.currentEndpoint); default: break; } self.disconnected(client: client); default: break; } } private func reportSaslError(on accountJID: BareJID, error: SaslError) { guard var account = AccountManager.getAccount(for: accountJID) else { return; } account.active = false; try? AccountManager.save(account: account); NotificationCenter.default.post(name: XmppService.AUTHENTICATION_ERROR, object: accountJID, userInfo: ["error": error]); } public enum ApplicationState { case active case inactive case suspended } public struct Status: Codable, Equatable { public static func == (lhs: XmppService.Status, rhs: XmppService.Status) -> Bool { guard lhs.shouldConnect == rhs.shouldConnect else { return false; } if (lhs.show == nil && rhs.show == nil) { return (lhs.message ?? "") == (rhs.message ?? ""); } else if let ls = lhs.show, let rs = rhs.show { return ls == rs && (lhs.message ?? "") == (rhs.message ?? ""); } else { return false; } } let show: Presence.Show?; let message: String?; let shouldConnect: Bool; let sendInitialPresence: Bool; init(show: Presence.Show?, message: String?, shouldConnect: Bool, sendInitialPresence: Bool) { self.show = show; self.message = message; self.shouldConnect = shouldConnect; self.sendInitialPresence = sendInitialPresence; } func with(show: Presence.Show?) -> Status { return Status(show: show, message: self.message, shouldConnect: shouldConnect, sendInitialPresence: sendInitialPresence); } func with(message: String?) -> Status { return Status(show: self.show, message: message, shouldConnect: shouldConnect, sendInitialPresence: sendInitialPresence); } func with(show: Presence.Show?, message: String?) -> Status { return Status(show: show, message: message, shouldConnect: shouldConnect, sendInitialPresence: sendInitialPresence); } func with(shouldConnect: Bool, sendInitialPresence: Bool) -> Status { return Status(show: show, message: message, shouldConnect: shouldConnect, sendInitialPresence: sendInitialPresence); } func toDict() -> [String : Any?] { var dict: [String: Any?] = [:]; if message != nil { dict["message"] = message; } if show != nil { dict["show"] = show?.rawValue; } return dict; } } } ================================================ FILE: SiskinIM/service/XmppServiceEventHandler.swift ================================================ // // XmppServiceEventHandler.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine protocol XmppServiceEventHandler: EventHandler { var events: [Event] { get } } protocol XmppServiceExtension { func register(for client: XMPPClient, cancellables: inout Set); } ================================================ FILE: SiskinIM/settings/AccountConnectivitySettingsViewController.swift ================================================ // // AccountConnectivitySettingsViewController.swift // // Siskin IM // Copyright (C) 2022 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import UIKit class AccountConnectivitySettingsViewController: UITableViewController { @IBOutlet var disableTls13Cell: UITableViewCell?; @IBOutlet var useDirectTlsCell: UITableViewCell?; @IBOutlet var hostField: UITextField?; @IBOutlet var portField: UITextField?; private var disableTls13Switch = UISwitch(); private var useDirectTlsSwitch = UISwitch(); var values: Settings?; override func viewDidLoad() { self.disableTls13Cell!.accessoryView = disableTls13Switch; self.useDirectTlsCell!.accessoryView = useDirectTlsSwitch; } override func viewWillAppear(_ animated: Bool) { hostField?.text = values?.host; if let port = values?.port { portField?.text = String(port); } else { portField?.text = nil; } useDirectTlsSwitch.isOn = values?.useDirectTLS ?? false; disableTls13Switch.isOn = values?.disableTLS13 ?? false; updateUseDirectTLS(); super.viewWillAppear(animated); } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated); values?.host = hostField?.text; values?.port = Int(portField?.text ?? ""); values?.useDirectTLS = (values?.host != nil && values?.port != nil) && useDirectTlsSwitch.isOn; values?.disableTLS13 = disableTls13Switch.isOn; } @IBAction func textFieldDidChange(_ sender: Any) { updateUseDirectTLS() } func updateUseDirectTLS() { self.useDirectTlsSwitch.isEnabled = !((hostField?.text?.isEmpty ?? true) || (portField?.text?.isEmpty ?? true)); } class Settings { var host: String?; var port: Int?; var useDirectTLS: Bool = false; var disableTLS13: Bool = false; } } ================================================ FILE: SiskinIM/settings/AccountDomainTableViewCell.swift ================================================ // // AccountDomainTableViewCell.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import UIKit class AccountDomainTableViewCell: UITableViewCell { @IBOutlet var domainField: UITextField! override func awakeFromNib() { super.awakeFromNib(); } } ================================================ FILE: SiskinIM/settings/AccountQRCodeController.swift ================================================ // // AccountQRCodeController.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class AccountQRCodeController: UIViewController { @IBOutlet var qrCodeView: UIImageView!; var account: BareJID?; override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); if let account = self.account { DBVCardStore.instance.vcard(for: account, completionHandler: { vcard in var dict: [String: String]? = nil; if let fn = vcard?.fn { dict = ["name": fn]; } else { if let given = vcard?.givenName, !given.isEmpty { if let surname = vcard?.surname, !surname.isEmpty { dict = ["name": "\(given) \(surname)"]; } else { dict = ["name": given]; } } else if let surname = vcard?.surname, !surname.isEmpty { dict = ["name": surname]; } else if let nick = vcard?.nicknames.first, !nick.isEmpty { dict = ["name": nick]; } } DispatchQueue.main.async { if let url = AppDelegate.XmppUri(jid: JID(account), action: nil, dict: dict).toURL()?.absoluteString, let qrCode = QRCode(string: url, scale: 10, foregroundColor: UIColor(named: "qrCodeForeground")!, backgroundColor: UIColor(named: "qrCodeBackground")!) { if let img = UIImage(named: "tigaseLogo") { let img2 = UIImage(cgImage: qrCode.cgImage); let renderer = UIGraphicsImageRenderer(size: qrCode.size); let rect = CGRect(origin: .zero, size: qrCode.size); self.qrCodeView.image = renderer.image(actions: { ctx in img.draw(in: rect, blendMode: .normal, alpha: 1.0); img2.draw(in: rect, blendMode: .normal, alpha: 1.0); }) } else { self.qrCodeView.image = UIImage(cgImage: qrCode.cgImage); } } } }); } } } class QRCode { static func generateQRCode(_ string: String) -> CIImage? { guard let qrFilter = CIFilter(name: "CIQRCodeGenerator") else { return nil; } qrFilter.setValue(string.data(using: .ascii), forKey: "inputMessage"); qrFilter.setValue("H", forKey: "inputCorrectionLevel"); return qrFilter.outputImage; } static func getCodes(ciImage: CIImage) -> [[Bool]]? { guard let cgImage = CIContext().createCGImage(ciImage, from: ciImage.extent) else { return nil; } let size = cgImage.width * cgImage.height * 4; var pixelData = [UInt8](repeating: 0, count: size); guard let cgContext = CGContext( data: &pixelData, width: cgImage.width, height: cgImage.height, bitsPerComponent: 8, bytesPerRow: 4 * cgImage.width, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return nil; } cgContext.draw(cgImage, in: ciImage.extent); return (0.. [Point] { let size = codeSize - 2; let version = ((size - 21) / 4) + 1; guard version != 1 else { return []; } let divs = 2 + version / 7; let total_dist = size - 7 - 6; let divisor = 2 * (divs - 1); let step = (total_dist + divisor / 2 + 1) / divisor * 2; let coords = [6] + (0...(divs-2)).map { size - 7 - (divs - 2 - $0) * step }; var points = [Point](); for x in coords { for y in coords { let fx = x + 1; let fy = y + 1; if !((fx == 7 && fy == 7) || (fx == 7 && fy == (codeSize - 8)) || (fx == (codeSize - 8) && fy == 7)) { points.append(Point(x: fx, y: fx)); } } } return points; } static func drawPoint(context: CGContext, rect: CGRect) { context.fillEllipse(in: rect); } static func isStatic(x: Int, y: Int, size: Int, points: [Point]) -> Bool { if (x == 0 || y == 0 || x == (size-1) || y == (size-1)) { return true; } let xOnEdge = (x<=8) || (x>=size-9); let yOnEdge = (y<=8) || (y>=size-9); if (xOnEdge && yOnEdge && !(x>=size-9 && y>=size-9)) { return true; } if x==7 || y==7 { return true; } return points.contains(where: { x >= ($0.x - 2) && x <= ($0.x + 2) && y >= ($0.y - 2) && y <= ($0.y + 2); }); } struct Point { let x: Int; let y: Int; } } ================================================ FILE: SiskinIM/settings/AccountSettingsViewController.swift ================================================ // // AccountSettingsViewController.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine class AccountSettingsViewController: UITableViewController { var account: BareJID!; @IBOutlet var avatarView: UIImageView! @IBOutlet var fullNameTextView: UILabel! @IBOutlet var companyTextView: UILabel! @IBOutlet var addressTextView: UILabel! @IBOutlet var enabledSwitch: UISwitch! @IBOutlet var nicknameLabel: UILabel!; @IBOutlet var pushNotificationsForAwaySwitch: UISwitch! @IBOutlet var archivingEnabledSwitch: UISwitch!; @IBOutlet var omemoFingerprint: UILabel!; private var cancellables: Set = []; override func viewDidLoad() { tableView.contentInset = UIEdgeInsets(top: -1, left: 0, bottom: 0, right: 0); } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); navigationItem.title = account.stringValue; AccountManager.accountEventsPublisher.receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] event in switch event { case .enabled(let account,_), .disabled(let account), .removed(let account): if self?.account == account.name { self?.updateView(); } } }).store(in: &cancellables); let config = AccountManager.getAccount(for: account); enabledSwitch.isOn = config?.active ?? false; nicknameLabel.text = config?.nickname; archivingEnabledSwitch.isOn = false; pushNotificationsForAwaySwitch.isOn = (config?.pushNotifications ?? false) && AccountSettings.pushNotificationsForAway(for: account); updateView(); DBVCardStore.instance.vcard(for: account, completionHandler: { vcard in DispatchQueue.main.async { self.update(vcard: vcard); } }) //avatarView.sizeToFit(); avatarView.layer.masksToBounds = true; avatarView.layer.cornerRadius = avatarView.frame.width / 2; AvatarManager.instance.avatarPublisher(for: .init(account: account, jid: account, mucNickname: nil)).avatarPublisher.receive(on: DispatchQueue.main).assign(to: \.image, on: avatarView).store(in: &cancellables); XmppService.instance.$connectedClients.map({ [weak self] clients in clients.first(where: { c in c.userBareJid == self?.account }) }).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] _ in DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self?.updateView(); } }).store(in: &cancellables); let localDeviceId = Int32(bitPattern: AccountSettings.omemoRegistrationId(for: self.account) ?? 0); if let omemoIdentity = DBOMEMOStore.instance.identities(forAccount: self.account, andName: self.account.stringValue).first(where: { (identity) -> Bool in return identity.address.deviceId == localDeviceId; }) { var fingerprint = String(omemoIdentity.fingerprint.dropFirst(2)); var idx = fingerprint.startIndex; for _ in 0..<(fingerprint.count / 8) { idx = fingerprint.index(idx, offsetBy: 8); fingerprint.insert(" ", at: idx); idx = fingerprint.index(after: idx); } omemoFingerprint.text = fingerprint; } else { omemoFingerprint.text = NSLocalizedString("Key not generated!", comment: "no OMEMO key - not generated yet"); } } override func viewDidAppear(_ animated: Bool) { //avatarView.sizeToFit(); avatarView.layer.masksToBounds = true; avatarView.layer.cornerRadius = avatarView.frame.width / 2; } override func viewDidDisappear(_ animated: Bool) { cancellables.removeAll(); super.viewDidDisappear(animated); } override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { if indexPath.row == 0 && indexPath.section == 1 { return nil; } if indexPath.section == 1 && indexPath.row == 1 && XmppService.instance.getClient(for: account)?.state != .connected() { return nil; } return indexPath; } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false); if indexPath.section == 1 && indexPath.row == 3 { let controller = UIAlertController(title: NSLocalizedString("Nickname", comment: "alert title"), message: NSLocalizedString("Enter default nickname to use in chats", comment: "alert body"), preferredStyle: .alert); controller.addTextField(configurationHandler: { textField in textField.text = AccountManager.getAccount(for: self.account)?.nickname ?? ""; }); controller.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: { _ in let nickname = controller.textFields?.first?.text?.trimmingCharacters(in: .whitespacesAndNewlines); if var account = AccountManager.getAccount(for: self.account) { account.nickname = nickname; try? AccountManager.save(account: account, reconnect: false); self.nicknameLabel.text = account.nickname; } })) self.navigationController?.present(controller, animated: true, completion: nil); } if indexPath.section == 5 && indexPath.row == 0 { self.deleteAccount(); } } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { if section == 0 { return nil; } return super.tableView(tableView, titleForHeaderInSection: section); } override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { if section == 0 { return 1.0; } return super.tableView(tableView, heightForHeaderInSection: section); } func updateView() { let client = XmppService.instance.getClient(for: account); if let pushModule = client?.module(.push) as? SiskinPushNotificationsModule { pushNotificationsForAwaySwitch.isEnabled = pushModule.isEnabled && pushModule.isSupported(extension: TigasePushNotificationsModule.PushForAway.self); } else { pushNotificationsForAwaySwitch.isEnabled = false; } archivingEnabledSwitch.isEnabled = false; if let mamModule = client?.module(.mam), mamModule.isAvailable { mamModule.retrieveSettings(completionHandler: { result in switch result { case .success(let settings): DispatchQueue.main.async { self.archivingEnabledSwitch.isEnabled = true; self.archivingEnabledSwitch.isOn = settings.defaultValue == .always; } case .failure(_): DispatchQueue.main.async { self.archivingEnabledSwitch.isOn = false; self.archivingEnabledSwitch.isEnabled = false; } } }) } } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { guard segue.identifier != nil else { return; } switch segue.identifier! { case "AccountQRCodeController": let destination = segue.destination as! AccountQRCodeController; destination.account = account; case "EditAccountSegue": let destination = segue.destination as! AddAccountController; destination.account = account.stringValue; case "EditAccountVCardSegue": let destination = segue.destination as! VCardEditViewController; destination.client = XmppService.instance.getClient(for: account); case "ShowServerFeatures": let destination = segue.destination as! ServerFeaturesViewController; destination.client = XmppService.instance.getClient(for: account); case "ManageOMEMOFingerprints": let destination = segue.destination as! OMEMOFingerprintsController; destination.account = account; default: break; } } @IBAction func enabledSwitchChangedValue(_ sender: AnyObject) { let newState = enabledSwitch.isOn; let account = self.account!; AccountSettings.lastError(for: account, value: nil); if newState { if var config = AccountManager.getAccount(for: account) { config.active = newState AccountSettings.lastError(for: account, value: nil); try? AccountManager.save(account: config); } } else { if let client = XmppService.instance.getClient(for: account), client.isConnected, let pushModule = client.module(.push) as? SiskinPushNotificationsModule, pushModule.isEnabled { self.enabledSwitch.isEnabled = false; pushModule.unregisterDeviceAndDisable(completionHandler: { result in if var config = AccountManager.getAccount(for: account) { config.active = newState; AccountSettings.lastError(for: account, value: nil); try? AccountManager.save(account: config); } self.enabledSwitch.isEnabled = true; }) } else { if var config = AccountManager.getAccount(for: account) { config.active = enabledSwitch.isOn; AccountSettings.lastError(for: account, value: nil); try? AccountManager.save(account: config); } } } } @IBAction func pushNotificationsForAwaySwitchChangedValue(_ sender: Any) { AccountSettings.pushNotificationsForAway(for: account, value: self.pushNotificationsForAwaySwitch.isOn); guard let pushModule = XmppService.instance.getClient(for: account)?.module(.push) as? SiskinPushNotificationsModule else { return; } guard let pushSettings = pushModule.pushSettings else { return; } pushModule.reenable(pushSettings: pushSettings, completionHandler: { (result) in switch result { case .success(_): DispatchQueue.main.async { guard self.pushNotificationsForAwaySwitch.isOn else { return; } } case .failure(_): DispatchQueue.main.async { self.pushNotificationsForAwaySwitch.isOn = !self.pushNotificationsForAwaySwitch.isOn; AccountSettings.pushNotificationsForAway(for: self.account, value: self.pushNotificationsForAwaySwitch.isOn); } } }); } @IBAction func archivingSwitchChangedValue(_ sender: Any) { if let mamModule = XmppService.instance.getClient(for: account)?.module(.mam){ let defValue = archivingEnabledSwitch.isOn ? MessageArchiveManagementModule.DefaultValue.always : MessageArchiveManagementModule.DefaultValue.never; mamModule.retrieveSettings(completionHandler: { result in switch result { case .success(let oldSettings): var newSettings = oldSettings; newSettings.defaultValue = defValue; mamModule.updateSettings(settings: newSettings, completionHandler: { result in switch result { case .success(let newSettings): DispatchQueue.main.async { self.archivingEnabledSwitch.isOn = newSettings.defaultValue == .always; } case .failure(_): DispatchQueue.main.async { self.archivingEnabledSwitch.isOn = oldSettings.defaultValue == .always; } } }); case .failure(_): DispatchQueue.main.async { self.archivingEnabledSwitch.isOn = !self.archivingEnabledSwitch.isOn; } } }); } } func update(vcard: VCard?) { if let fn = vcard?.fn { fullNameTextView.text = fn; } else if let surname = vcard?.surname, let given = vcard?.givenName { fullNameTextView.text = "\(given) \(surname)"; } else { fullNameTextView.text = account.stringValue; } let company = vcard?.organizations.first?.name; let role = vcard?.role; if role != nil && company != nil { companyTextView.text = "\(role!) at \(company!)"; companyTextView.isHidden = false; } else if company != nil { companyTextView.text = company; companyTextView.isHidden = false; } else if role != nil { companyTextView.text = role; companyTextView.isHidden = false; } else { companyTextView.isHidden = true; } let addresses = vcard?.addresses.filter { (addr) -> Bool in return !addr.isEmpty; }; if let address = addresses?.first { var tmp = [String](); if address.street != nil { tmp.append(address.street!); } if address.locality != nil { tmp.append(address.locality!); } if address.country != nil { tmp.append(address.country!); } addressTextView.text = tmp.joined(separator: ", "); } else { addressTextView.text = nil; } } func deleteAccount() { guard let account = self.account, var config = AccountManager.getAccount(for: account) else { return; } let removeAccount: (BareJID, Bool)->Void = { account, fromServer in if fromServer { if let client = XmppService.instance.getClient(for: account), client.state == .connected() { let regModule = client.modulesManager.register(InBandRegistrationModule()); regModule.unregister(completionHander: { (result) in DispatchQueue.main.async() { try? AccountManager.deleteAccount(for: account); self.navigationController?.popViewController(animated: true); } }); } else { DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Account removal", comment: "alert title"), message: NSLocalizedString("Could not delete account as it was not possible to connect to the XMPP server. Please try again later.", comment: "alert body"), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: { _ in self.tableView.reloadData(); })); self.present(alert, animated: true, completion: nil); } } } else { DispatchQueue.main.async { try? AccountManager.deleteAccount(for: account); self.navigationController?.popViewController(animated: true); } } }; self.askAboutAccountRemoval(account: account, atRow: IndexPath(row: 0, section: 5), completionHandler: { result in switch result { case .success(let removeFromServer): if let pushSettings = config.pushSettings { if let client = XmppService.instance.getClient(for: account), client.state == .connected(), let pushModule = client.module(.push) as? SiskinPushNotificationsModule { pushModule.unregisterDeviceAndDisable(completionHandler: { result in switch result { case .success(_): // now remove the account... removeAccount(account, removeFromServer) break; case .failure(_): PushEventHandler.unregisterDevice(from: pushSettings.jid.bareJid, account: account, deviceId: pushSettings.deviceId, completionHandler: { result in config.pushSettings = nil; try? AccountManager.save(account: config); DispatchQueue.main.async { switch result { case .success(_): removeAccount(account, removeFromServer); case .failure(_): let alert = UIAlertController(title: NSLocalizedString("Account removal", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Push notifications are enabled for %@. They need to be disabled before account can be removed and it is not possible to at this time. Please try again later.", comment: "alert body"), account.stringValue), preferredStyle: .alert); alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)); self.present(alert, animated: true, completion: nil); } } }) } }); } else { PushEventHandler.unregisterDevice(from: pushSettings.jid.bareJid, account: account, deviceId: pushSettings.deviceId, completionHandler: { result in DispatchQueue.main.async { switch result { case .success(_): config.pushSettings = nil; try? AccountManager.save(account: config); removeAccount(account, removeFromServer); case .failure(_): let alert = UIAlertController(title: NSLocalizedString("Account removal", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Push notifications are enabled for %@. They need to be disabled before account can be removed and it is not possible to at this time. Please try again later.", comment: "alert body"), account.stringValue), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); self.present(alert, animated: true, completion: nil); } } }) } } else { removeAccount(account, removeFromServer); } case .failure(_): break; } }) } func askAboutAccountRemoval(account: BareJID, atRow indexPath: IndexPath, completionHandler: @escaping (Result)->Void) { let client = XmppService.instance.getClient(for: account) let alert = UIAlertController(title: NSLocalizedString("Account removal", comment: "alert title"), message: client != nil ? NSLocalizedString("Should account be removed from server as well?", comment: "alert body") : NSLocalizedString("Remove account from application?", comment: "alert body"), preferredStyle: .actionSheet); if client?.state == .connected() { alert.addAction(UIAlertAction(title: NSLocalizedString("Remove from server", comment: "button label"), style: .destructive, handler: { (action) in completionHandler(.success(true)); })); } alert.addAction(UIAlertAction(title: NSLocalizedString("Remove from application", comment: "button label"), style: .default, handler: { (action) in completionHandler(.success(false)); })); alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .default, handler: nil)); alert.popoverPresentationController?.sourceView = self.tableView; alert.popoverPresentationController?.sourceRect = self.tableView.rectForRow(at: indexPath); self.present(alert, animated: true, completion: nil); } } ================================================ FILE: SiskinIM/settings/AccountTableViewCell.swift ================================================ // // AccountTableViewCell.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine class AccountTableViewCell: UITableViewCell { @IBOutlet var avatarStatusView: AvatarStatusView! @IBOutlet var nameLabel: UILabel! @IBOutlet var descriptionLabel: UILabel!; private var cancellables: Set = []; private var avatarObj: Avatar? { didSet { avatarObj?.avatarPublisher.receive(on: DispatchQueue.main).assign(to: \.avatar, on: avatarStatusView.avatarImageView).store(in: &cancellables); } } override var backgroundColor: UIColor? { get { return super.backgroundColor; } set { super.backgroundColor = newValue; avatarStatusView?.backgroundColor = newValue; } } override func awakeFromNib() { super.awakeFromNib() // Initialization code } func set(account accountJid: BareJID) { cancellables.removeAll(); avatarObj = AvatarManager.instance.avatarPublisher(for: .init(account: accountJid, jid: accountJid, mucNickname: nil)); nameLabel.text = accountJid.stringValue; if let acc = AccountManager.getAccount(for: accountJid) { descriptionLabel.text = acc.nickname; if acc.active { avatarStatusView.statusImageView.isHidden = false; acc.state.map({ value -> Presence.Show? in switch value { case .connected(_): return .online case .connecting, .disconnecting: return .xa default: return nil; } }).receive(on: DispatchQueue.main).assign(to: \.status, on: avatarStatusView).store(in: &cancellables); } else { avatarStatusView.statusImageView.isHidden = true; } } else { avatarStatusView.statusImageView.isHidden = false; descriptionLabel.text = nil; } } } ================================================ FILE: SiskinIM/settings/AddAccountController.swift ================================================ // // AddAccountController.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine import Shared class AddAccountController: UITableViewController, UITextFieldDelegate { var account:String?; @IBOutlet var jidTextField: UITextField! @IBOutlet var passwordTextField: UITextField! @IBOutlet var cancelButton: UIBarButtonItem!; @IBOutlet var saveButton: UIBarButtonItem! var activityInditcator: UIActivityIndicatorView?; var xmppClient: XMPPClient?; var accountValidatorTask: AccountValidatorTask?; var connectivitySettings = AccountConnectivitySettingsViewController.Settings(); // var onAccountAdded: (() -> Void)?; override func viewDidLoad() { super.viewDidLoad(); if account != nil { jidTextField.text = account; passwordTextField.text = AccountManager.getAccountPassword(for: BareJID(account)!); jidTextField.isEnabled = false; if let acc = AccountManager.getAccount(for: BareJID(account)!) { connectivitySettings.disableTLS13 = acc.disableTLS13; if let endpoint = acc.endpoint { connectivitySettings.host = endpoint.host; connectivitySettings.port = endpoint.port; connectivitySettings.useDirectTLS = endpoint.proto == .XMPPS; } } } else { navigationController?.navigationItem.leftBarButtonItem = nil; } saveButton.title = "Save"; jidTextField.delegate = self; passwordTextField.delegate = self; jidTextField.keyboardType = .emailAddress; if #available(iOS 11.0, *) { jidTextField.textContentType = .username; passwordTextField.textContentType = .password; }; } override func viewWillAppear(_ animated: Bool) { updateSaveButtonState(); super.viewWillAppear(animated); } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated); } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if let advSettingsController = segue.destination as? AccountConnectivitySettingsViewController { advSettingsController.values = connectivitySettings; } } override func viewWillDisappear(_ animated: Bool) { // onAccountAdded = nil; super.viewWillDisappear(animated); } @IBAction func jidTextFieldChanged(_ sender: UITextField) { updateSaveButtonState(); } @IBAction func passwordTextFieldChanged(_ sender: AnyObject) { updateSaveButtonState(); } func updateSaveButtonState() { let disable = (jidTextField.text?.isEmpty ?? true) || (passwordTextField.text?.isEmpty ?? true); saveButton.isEnabled = !disable; } @IBAction func saveClicked(_ sender: UIBarButtonItem) { //saveAccount(); validateAccount(); } func validateAccount() { guard let jid = BareJID(self.jidTextField.text), let password = self.passwordTextField.text, !password.isEmpty else { return; } self.saveButton.isEnabled = false; showIndicator(); self.accountValidatorTask = AccountValidatorTask(controller: self); self.accountValidatorTask?.check(account: jid, password: password, connectivitySettings: connectivitySettings, callback: self.handleResult); } func saveAccount(acceptedCertificate: SslCertificateInfo?) { guard let jid = BareJID(jidTextField.text) else { return; } var account = AccountManager.getAccount(for: jid) ?? AccountManager.Account(name: jid); account.acceptCertificate(acceptedCertificate); account.password = passwordTextField.text!; if let host = connectivitySettings.host, let port = connectivitySettings.port { account.endpoint = .init(proto: connectivitySettings.useDirectTLS ? .XMPPS : .XMPP, host: host, port: port) } account.disableTLS13 = connectivitySettings.disableTLS13; var cancellables: Set = []; do { try AccountManager.save(account: account); self.dismissView(); (UIApplication.shared.delegate as? AppDelegate)?.showSetup(value: false); } catch { self.hideIndicator(); cancellables.removeAll(); let alert = UIAlertController(title: NSLocalizedString("Error", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("It was not possible to save account details: %@", comment: "alert body"), error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default)); self.present(alert, animated: true, completion: nil); } } @IBAction func cancelClicked(_ sender: UIBarButtonItem) { dismissView(); } func dismissView() { let dismiss = self.view.window?.rootViewController is SetupViewController; // onAccountAdded = nil; accountValidatorTask?.finish(); accountValidatorTask = nil; if dismiss { navigationController?.dismiss(animated: true, completion: nil); } else { let newController = navigationController?.popViewController(animated: true); if newController == nil || newController != self { let emptyDetailController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "emptyDetailViewController"); self.showDetailViewController(emptyDetailController, sender: self); } } } override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { if indexPath.section == 0 && indexPath.row == 0 && !jidTextField.isEnabled { return nil; } return indexPath; } func textFieldShouldReturn(_ textField: UITextField) -> Bool { if textField == jidTextField { passwordTextField.becomeFirstResponder(); } else { DispatchQueue.main.async { self.validateAccount(); } } textField.resignFirstResponder(); return false; } func handleResult(result: Result) { let acceptedCertificate = accountValidatorTask?.acceptedCertificate; self.accountValidatorTask = nil; switch result { case .failure(let errorCondition): self.hideIndicator(); self.saveButton.isEnabled = true; var error = ""; switch errorCondition { case .not_authorized: error = NSLocalizedString("Login and password do not match.", comment: "error message"); default: error = NSLocalizedString("It was not possible to contact XMPP server and sign in.", comment: "error message"); } let alert = UIAlertController(title: NSLocalizedString("Error", comment: "alert title"), message: error, preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("Close", comment: "button label"), style: .cancel, handler: nil)); self.present(alert, animated: true, completion: nil); case .success(_): self.saveAccount(acceptedCertificate: acceptedCertificate); } } func showIndicator() { if activityInditcator != nil { hideIndicator(); } activityInditcator = UIActivityIndicatorView(style: .medium); activityInditcator?.center = CGPoint(x: view.frame.width/2, y: view.frame.height/2); activityInditcator!.isHidden = false; activityInditcator!.startAnimating(); view.addSubview(activityInditcator!); view.bringSubviewToFront(activityInditcator!); } func hideIndicator() { activityInditcator?.stopAnimating(); activityInditcator?.removeFromSuperview(); activityInditcator = nil; } class AccountValidatorTask: EventHandler { private var cancellables: Set = []; var client: XMPPClient? { willSet { if newValue != nil { newValue?.eventBus.register(handler: self, for: SaslModule.SaslAuthSuccessEvent.TYPE, SaslModule.SaslAuthFailedEvent.TYPE); } } didSet { cancellables.removeAll(); if oldValue != nil { _ = oldValue?.disconnect(true); oldValue?.eventBus.unregister(handler: self, for: SaslModule.SaslAuthSuccessEvent.TYPE, SaslModule.SaslAuthFailedEvent.TYPE); } client?.$state.sink(receiveValue: { [weak self] state in self?.changedState(state) }).store(in: &cancellables); } } var callback: ((Result)->Void)? = nil; weak var controller: UIViewController?; var dispatchQueue = DispatchQueue(label: "accountValidatorSync"); var acceptedCertificate: SslCertificateInfo? = nil; init(controller: UIViewController) { self.controller = controller; initClient(); } fileprivate func initClient() { self.client = XMPPClient(); _ = client?.modulesManager.register(StreamFeaturesModule()); _ = client?.modulesManager.register(SaslModule()); _ = client?.modulesManager.register(AuthModule()); } public func check(account: BareJID, password: String, connectivitySettings: AccountConnectivitySettingsViewController.Settings, callback: @escaping (Result)->Void) { self.callback = callback; client?.connectionConfiguration.useSeeOtherHost = false; client?.connectionConfiguration.userJid = account; client?.connectionConfiguration.modifyConnectorOptions(type: SocketConnectorNetwork.Options.self, { options in if let host = connectivitySettings.host, let port = connectivitySettings.port { options.connectionDetails = .init(proto: connectivitySettings.useDirectTLS ? .XMPPS : .XMPP, host: host, port: port) } options.networkProcessorProviders.append(connectivitySettings.disableTLS13 ? SSLProcessorProvider(supportedTlsVersions: TLSVersion.TLSv1_2...TLSVersion.TLSv1_2) : SSLProcessorProvider()); }) client?.connectionConfiguration.credentials = .password(password: password, authenticationName: nil, cache: nil); client?.login(); } public func handle(event: Event) { dispatchQueue.sync { guard let callback = self.callback else { return; } var param: ErrorCondition? = nil; switch event { case is SaslModule.SaslAuthSuccessEvent: param = nil; case is SaslModule.SaslAuthFailedEvent: param = ErrorCondition.not_authorized; default: param = ErrorCondition.service_unavailable; } DispatchQueue.main.async { if let error = param { callback(.failure(error)); } else { callback(.success(Void())); } } self.finish(); } } func changedState(_ state: XMPPClient.State) { dispatchQueue.sync { guard let callback = self.callback else { return; } switch state { case .disconnected(let reason): switch reason { case .sslCertError(let trust): self.callback = nil; let certData = SslCertificateInfo(trust: trust); let alert = CertificateErrorAlert.create(domain: self.client!.sessionObject.userBareJid!.domain, certData: certData, onAccept: { self.acceptedCertificate = certData; self.client?.connectionConfiguration.modifyConnectorOptions(type: SocketConnectorNetwork.Options.self, { options in options.networkProcessorProviders.append(SSLProcessorProvider()); options.sslCertificateValidation = .fingerprint(certData.details.fingerprintSha1); }); self.callback = callback; self.client?.login(); }, onDeny: { self.finish(); callback(.failure(ErrorCondition.service_unavailable)); }) DispatchQueue.main.async { self.controller?.present(alert, animated: true, completion: nil); } return; default: break; } DispatchQueue.main.async { callback(.failure(.service_unavailable)); } self.finish(); default: break; } } } public func finish() { self.callback = nil; self.client = nil; self.controller = nil; } } } ================================================ FILE: SiskinIM/settings/BlockedContactsController.swift ================================================ // // BlockedContactsController.swift // Siskin IM // // Created by Andrzej Wójcik on 24/11/2019. // Copyright © 2019 Tigase, Inc. All rights reserved. // import UIKit import Martin class BlockedContactsController: UITableViewController { var activityIndicator: UIActivityIndicatorView!; private var allItems: [Item] = []; private var items: [Item] = []; override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); let clients = XmppService.instance.connectedClients; var items: [Item] = []; if !clients.isEmpty { showIndicator(); let group = DispatchGroup(); for client in clients { group.enter(); DispatchQueue.global().async { let account = client.userBareJid; client.module(.blockingCommand).retrieveBlockedJids(completionHandler: { result in DispatchQueue.main.async { switch result { case .success(let jids): items.append(contentsOf: jids.map({ jid -> Item in return Item(account: account, jid: jid); })); case .failure(_): break; } } group.leave(); }); } } group.notify(queue: DispatchQueue.main, execute: { self.allItems = items.sorted(); self.updateItems(); self.hideIndicator(); }) } } override func numberOfSections(in tableView: UITableView) -> Int { return 1; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if items.count == 0 { return 1; } return items.count; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard items.count > 0 else { let cell = tableView.dequeueReusableCell(withIdentifier: "BlockedContactTableViewEmptyCell", for: indexPath); return cell; } let cell = tableView.dequeueReusableCell(withIdentifier: "BlockedContactTableViewCell", for: indexPath); let item = items[indexPath.row]; cell.textLabel?.text = item.jid.stringValue; cell.detailTextLabel?.text = item.account.stringValue; return cell; } override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard items.count > 0 else { return nil; } return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { suggestedActions -> UIMenu? in return UIMenu(title: "", children: [ UIAction(title: NSLocalizedString("Unblock", comment: "button label"), image: UIImage(systemName: "hand.raised.slash"), attributes: [.destructive], handler: { action in self.unblock(at: indexPath); }) ]); }; } func unblock(at indexPath: IndexPath) { let item = items[indexPath.row]; guard let client = XmppService.instance.getClient(for: item.account), client.state == .connected() else { return; } let blockingModule = client.module(.blockingCommand); guard blockingModule.isAvailable else { return; } showIndicator(); blockingModule.unblock(jids: [item.jid], completionHandler: { [weak self] result in DispatchQueue.main.async { self?.hideIndicator(); } switch result { case .success(_): DispatchQueue.main.async { guard let that = self else { return; } that.allItems.removeAll { (it) -> Bool in return it == item; }; if let idx = that.items.firstIndex(of: item) { that.items.remove(at: idx); that.tableView.performBatchUpdates({ that.tableView.deleteRows(at: [IndexPath(item: idx, section: 0)], with: .automatic); if that.items.count == 0 { that.tableView.insertRows(at: [IndexPath(item: 0, section: 0)], with: .automatic); } }, completion: nil); } } case .failure(_): break; } }); } func updateItems() { self.items = allItems; tableView.reloadData(); } func showIndicator() { if activityIndicator != nil { hideIndicator(); } activityIndicator = UIActivityIndicatorView(style: .medium); activityIndicator?.center = CGPoint(x: view.frame.width/2, y: view.frame.height/2); activityIndicator!.isHidden = false; activityIndicator!.startAnimating(); view.addSubview(activityIndicator!); view.bringSubviewToFront(activityIndicator!); } func hideIndicator() { activityIndicator?.stopAnimating(); activityIndicator?.removeFromSuperview(); activityIndicator = nil; } struct Item: Equatable, Comparable { static func < (i1: BlockedContactsController.Item, i2: BlockedContactsController.Item) -> Bool { switch i1.jid.stringValue.compare(i2.jid.stringValue) { case.orderedAscending: return true; case .orderedDescending: return false; case .orderedSame: return i1.account.stringValue.compare(i2.account.stringValue) == .orderedAscending; } } let account: BareJID; let jid: JID; } } ================================================ FILE: SiskinIM/settings/ChatSettingsViewController.swift ================================================ // // ChatSettingsViewController.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class ChatSettingsViewController: UITableViewController { let tree: [[SettingsEnum]] = { return [ [SettingsEnum.recentsMessageLinesNo], [SettingsEnum.sendMessageOnReturn, SettingsEnum.messageDeliveryReceipts, SettingsEnum.messageEncryption, SettingsEnum.linkPreviews], [SettingsEnum.media] ]; }(); override func numberOfSections(in tableView: UITableView) -> Int { return tree.count; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return tree[section].count; } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { switch section { case 0: return NSLocalizedString("List of messages", comment: "section label") case 1: return NSLocalizedString("Messages", comment: "section label") case 2: return NSLocalizedString("Attachments", comment: "section label") default: return nil; } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let setting = tree[indexPath.section][indexPath.row]; switch setting { case .recentsMessageLinesNo: let cell = tableView.dequeueReusableCell(withIdentifier: "RecentsMessageLinesNoTableViewCell", for: indexPath ) as! StepperTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$recentsMessageLinesNo, labelGenerator: { val in return val == 1 ? NSLocalizedString("1 line of preview", comment: "no. of lines of messages preview label") : String.localizedStringWithFormat(NSLocalizedString("%d lines of preview", comment: "no. of lines of messages preview label"), val); }); cell.sink(to: \.recentsMessageLinesNo, on: Settings); }) return cell; case .messageDeliveryReceipts: let cell = tableView.dequeueReusableCell(withIdentifier: "MessageDeliveryReceiptsTableViewCell", for: indexPath) as! SwitchTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$confirmMessages); cell.sink(to: \.confirmMessages, on: Settings); }) return cell; case .linkPreviews: let cell = tableView.dequeueReusableCell(withIdentifier: "LinkPreviewsTableViewCell", for: indexPath) as! SwitchTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$linkPreviews); cell.sink(to: \.linkPreviews, on: Settings); }) return cell; case .sendMessageOnReturn: let cell = tableView.dequeueReusableCell(withIdentifier: "SendMessageOnReturnTableViewCell", for: indexPath) as! SwitchTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$sendMessageOnReturn); cell.sink(to: \.sendMessageOnReturn, on: Settings); }) return cell; case .messageEncryption: let cell = tableView.dequeueReusableCell(withIdentifier: "MessageEncryptionTableViewCell", for: indexPath) as! EnumTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$messageEncryption); }) cell.accessoryType = .disclosureIndicator; return cell; case .media: let cell = tableView.dequeueReusableCell(withIdentifier: "MediaSettingsViewCell", for: indexPath); return cell; } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true); let setting = tree[indexPath.section][indexPath.row]; switch setting { case .messageEncryption: let controller = TablePickerViewController(style: .grouped, message: NSLocalizedString("Select default conversation encryption", comment: "selection information"), options: [.none, .omemo], value: Settings.messageEncryption); controller.sink(to: \.messageEncryption, on: Settings) self.navigationController?.pushViewController(controller, animated: true); default: break; } } internal enum SettingsEnum { case recentsMessageLinesNo case messageDeliveryReceipts case linkPreviews case sendMessageOnReturn case messageEncryption case media } } ================================================ FILE: SiskinIM/settings/ContactsSettingsViewController.swift ================================================ // // ContactsSettingsViewController.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class ContactsSettingsViewController: UITableViewController { let tree: [[SettingsEnum]] = [ [SettingsEnum.rosterType, SettingsEnum.rosterDisplayHiddenGroup], [SettingsEnum.autoSubscribeOnAcceptedSubscriptionRequest, .blockedContacts], ]; override func numberOfSections(in tableView: UITableView) -> Int { return tree.count; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return tree[section].count; } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { switch section { case 0: return NSLocalizedString("Display", comment: "section label"); case 1: return NSLocalizedString("General", comment: "section label"); default: return nil; } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let setting = tree[indexPath.section][indexPath.row]; switch setting { case .rosterType: let cell = tableView.dequeueReusableCell(withIdentifier: "RosterTypeTableViewCell", for: indexPath ) as! SwitchTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$rosterType.map({ $0 == .grouped ? true : false }).eraseToAnyPublisher()); cell.sink(map: { $0 ? .grouped : .flat }, to: \.rosterType, on: Settings); }) return cell; case .rosterDisplayHiddenGroup: let cell = tableView.dequeueReusableCell(withIdentifier: "RosterHiddenGroupTableViewCell", for: indexPath) as! SwitchTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$rosterDisplayHiddenGroup); cell.sink(to: \.rosterDisplayHiddenGroup, on: Settings); }) return cell; case .autoSubscribeOnAcceptedSubscriptionRequest: let cell = tableView.dequeueReusableCell(withIdentifier: "AutoSubscribeOnAcceptedSubscriptionRequestTableViewCell", for: indexPath) as! SwitchTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$autoSubscribeOnAcceptedSubscriptionRequest); cell.sink(to: \.autoSubscribeOnAcceptedSubscriptionRequest, on: Settings); }); return cell; case .blockedContacts: return tableView.dequeueReusableCell(withIdentifier: "BlockedContactsTableViewCell", for: indexPath); } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath as IndexPath, animated: true); } internal enum SettingsEnum: Int { case rosterType = 0 case rosterDisplayHiddenGroup = 1 case autoSubscribeOnAcceptedSubscriptionRequest = 2 case blockedContacts = 3 } } ================================================ FILE: SiskinIM/settings/DeviceMemoryUsageTableViewCell.swift ================================================ // // DeviceMemoryUsageTableViewCell.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class DeviceMemoryUsageTableViewCell: UITableViewCell { let chartView = UsageChartView(); var diskSpace = DiskSpace.current(); override func awakeFromNib() { super.awakeFromNib(); chartView.translatesAutoresizingMaskIntoConstraints = false; contentView.addSubview(chartView); NSLayoutConstraint.activate([ contentView.topAnchor.constraint(equalTo: chartView.topAnchor, constant: -20), contentView.leadingAnchor.constraint(equalTo: chartView.leadingAnchor, constant: -20), contentView.trailingAnchor.constraint(equalTo: chartView.trailingAnchor, constant: 20), contentView.bottomAnchor.constraint(equalTo: chartView.bottomAnchor, constant: 20) ]); chartView.maximumValue = Double(diskSpace.total); let downloadsSize = DownloadStore.instance.size; let metadataSize = MetadataCache.instance.size; let usedByUs = downloadsSize + metadataSize; chartView.items = [ .init(color: .systemYellow, value: Double(downloadsSize), name: NSLocalizedString("Downloads", comment: "memory usage label")), .init(color: .systemGreen, value: Double(metadataSize), name: NSLocalizedString("Link previews", comment: "memory usage label")), .init(color: .lightGray, value: Double(diskSpace.used - usedByUs), name: NSLocalizedString("Other apps", comment: "memory usage label")), .init(color: .systemGray, value: Double(diskSpace.free), name: NSLocalizedString("Free", comment: "memory usage label")) ] } struct DiskSpace { let total: Int; let free: Int; var used: Int { return total - free; } static func current() -> DiskSpace { do { let attrs = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()); let total = (attrs[.systemSize] as? NSNumber)?.intValue ?? 0; let free = (attrs[.systemFreeSize] as? NSNumber)?.intValue ?? 0; return .init(total: total, free: free); } catch { return DiskSpace(total: 0, free: 0); } } } } ================================================ FILE: SiskinIM/settings/ExperimentalSettingsViewController.swift ================================================ // // ExperimentalSettingsViewController.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class ExperimentalSettingsViewController: UITableViewController { override func numberOfSections(in tableView: UITableView) -> Int { return 1; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return 5; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let setting = SettingsEnum(rawValue: indexPath.row)!; switch setting { case .notificationsFromUnknown: let cell = tableView.dequeueReusableCell(withIdentifier: "XmppPipeliningTableViewCell", for: indexPath) as! SwitchTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$xmppPipelining); cell.sink(to: \.xmppPipelining, on: Settings); }) return cell; case .enableBookmarksSync: let cell = tableView.dequeueReusableCell(withIdentifier: "EnableBookmarksSyncTableViewCell", for: indexPath) as! SwitchTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$enableBookmarksSync); cell.sink(to: \.enableBookmarksSync, on: Settings); }) return cell; case .enableMarkdown: let cell = tableView.dequeueReusableCell(withIdentifier: "EnableMarkdownTableViewCell", for: indexPath) as! SwitchTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$enableMarkdownFormatting); cell.sink(to: \.enableMarkdownFormatting, on: Settings); }) return cell; case .showEmoticons: let cell = tableView.dequeueReusableCell(withIdentifier: "EnableEmoticonsTableViewCell", for: indexPath) as! SwitchTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$showEmoticons); cell.sink(to: \.showEmoticons, on: Settings); }) return cell; case .usePublicStunServers: let cell = tableView.dequeueReusableCell(withIdentifier: "PublicStunServersTableViewCell", for: indexPath) as! SwitchTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$usePublicStunServers); cell.sink(to: \.usePublicStunServers, on: Settings); }) return cell; } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath as IndexPath, animated: true); } internal enum SettingsEnum: Int { case notificationsFromUnknown = 0 case enableBookmarksSync = 1 case enableMarkdown = 2 case showEmoticons = 3 case usePublicStunServers = 4 } } ================================================ FILE: SiskinIM/settings/MediaSettingsVIewController.swift ================================================ // // MediaSettingsVIewController.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Shared class MediaSettingsViewController: UITableViewController { let tree: [[SettingsEnum]] = [ [SettingsEnum.sharingViaHttpUpload, SettingsEnum.maxImagePreviewSize], [SettingsEnum.imageUploadQuality, SettingsEnum.videoUploadQuality], [SettingsEnum.deviceMemoryUsage, SettingsEnum.clearDownloadStore, SettingsEnum.clearMetadataStore] ]; override func numberOfSections(in tableView: UITableView) -> Int { return tree.count; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return tree[section].count; } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { switch section { case 0: return NSLocalizedString("Sharing", comment: "section label"); case 1: return NSLocalizedString("Quality of uploaded media", comment: "section label"); case 2: return String.localizedStringWithFormat(NSLocalizedString("%@ memory", comment: "section label, device memory"), UIDevice.current.localizedModel); default: return nil; } } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { switch section { case 0: return NSLocalizedString("Limits the size of the files sent to you which may be automatically downloaded", comment: "option description"); case 1: return NSLocalizedString("Used image and video quality may impact storage and network usage", comment: "option description"); case 2: return NSLocalizedString("Removal of cached attachments may lead to increased usage of network, if attachment may need to be redownloaded, or to lost files, if they are no longer available at the server.", comment: "option description"); default: return nil; } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let setting = tree[indexPath.section][indexPath.row]; switch setting { case .imageUploadQuality: let cell = tableView.dequeueReusableCell(withIdentifier: "ImageQualityTableViewCell", for: indexPath) as! EnumTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$imageQuality.map({ $0.label as String? }).eraseToAnyPublisher()); }) return cell; case .videoUploadQuality: let cell = tableView.dequeueReusableCell(withIdentifier: "VideoQualityTableViewCell", for: indexPath) as! EnumTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$videoQuality.map({ $0.label as String? }).eraseToAnyPublisher()); }) return cell; case .sharingViaHttpUpload: let cell = tableView.dequeueReusableCell(withIdentifier: "SharingViaHttpUploadTableViewCell", for: indexPath ) as! SwitchTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$sharingViaHttpUpload); }) cell.switchView.isOn = Settings.sharingViaHttpUpload; cell.valueChangedListener = {(switchView: UISwitch) in if switchView.isOn { let alert = UIAlertController(title: nil, message: NSLocalizedString("When you share files using HTTP, they are uploaded to HTTP server with unique URL. Anyone who knows the unique URL to the file is able to download it.\nDo you wish to enable?", comment: "alert body"), preferredStyle: .alert); alert.addAction(UIAlertAction(title: "Yes", style: .default, handler: { (action) in Settings.sharingViaHttpUpload = true; })); alert.addAction(UIAlertAction(title: "No", style: .cancel, handler: { (action) in switchView.isOn = false; })); self.present(alert, animated: true, completion: nil); } else { Settings.sharingViaHttpUpload = false; } } return cell; case .maxImagePreviewSize: let cell = tableView.dequeueReusableCell(withIdentifier: "MaxImagePreviewSizeTableViewCell", for: indexPath) as! EnumTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$fileDownloadSizeLimit.map({ value in return value == Int.max ? NSLocalizedString("Unlimited", comment: "allowed size of file to download") : "\(value) MB"; }).eraseToAnyPublisher()); }) cell.accessoryType = .disclosureIndicator; return cell; case .deviceMemoryUsage: let cell = tableView.dequeueReusableCell(withIdentifier: "DeviceMemoryUsageTableViewCell", for: indexPath); return cell; case .clearDownloadStore: return tableView.dequeueReusableCell(withIdentifier: "ClearDownloadStoreTableViewCell", for: indexPath); case .clearMetadataStore: return tableView.dequeueReusableCell(withIdentifier: "ClearMetadataStoreTableViewCell", for: indexPath); } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true); let setting = tree[indexPath.section][indexPath.row]; switch setting { case .maxImagePreviewSize: let controller = TablePickerViewController(style: .grouped, options: [0, 1, 2, 4, 8, 10, 15, 30, 50, Int.max], value: Settings.fileDownloadSizeLimit, labelFn: { value in return value == Int.max ? NSLocalizedString("Unlimited", comment: "allowed size of file to download") : "\(value) MB"; }); controller.sink(to: \.fileDownloadSizeLimit, on: Settings); self.navigationController?.pushViewController(controller, animated: true); case .imageUploadQuality: let controller = TablePickerViewController(style: .grouped, message: NSLocalizedString("Select quality of the image to use for sharing", comment: "selection description"), footer: NSLocalizedString("Original quality will share image in the format in which it is stored on your phone and it may not be supported by every device.", comment: "selection warning"), options: [.original, .highest, .high, .medium, .low], value: Settings.imageQuality, labelFn: { $0.label }); controller.sink(to: \.imageQuality, on: Settings); self.navigationController?.pushViewController(controller, animated: true); case .videoUploadQuality: let controller = TablePickerViewController(style: .grouped, message: NSLocalizedString("Select quality of the video to use for sharing", comment: "selection description"), footer: NSLocalizedString("Original quality will share video in the format in which video is stored on your phone and it may not be supported by every device.", comment: "selection warning"), options: [.original, .high, .medium, .low], value: Settings.videoQuality, labelFn: { $0.label }); controller.sink(to: \.videoQuality, on: Settings); self.navigationController?.pushViewController(controller, animated: true); case .clearDownloadStore: let formatter = ByteCountFormatter(); formatter.allowedUnits = [.useKB,.useMB,.useGB,.useTB]; formatter.countStyle = .memory; let alert = UIAlertController(title: NSLocalizedString("Download storage", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("We are using %@ of storage.", comment: "used space label"), formatter.string(fromByteCount: Int64(DownloadStore.instance.size))), preferredStyle: .actionSheet); alert.addAction(UIAlertAction(title: NSLocalizedString("Everything", comment: "option to remove all data from local storage"), style: .destructive, handler: {(action) in DispatchQueue.global(qos: .background).async { DownloadStore.instance.clear(); } })); alert.addAction(UIAlertAction(title: NSLocalizedString("Older than 7 days", comment: "option to remove data older than 7 days"), style: .destructive, handler: {(action) in DispatchQueue.global(qos: .background).async { DownloadStore.instance.clear(olderThan: Date().addingTimeInterval(7*24*60*60.0*(-1.0))); } })); alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); alert.popoverPresentationController?.sourceView = self.tableView; alert.popoverPresentationController?.sourceRect = self.tableView.rectForRow(at: indexPath); self.present(alert, animated: true, completion: nil); break; case .clearMetadataStore: let formatter = ByteCountFormatter(); formatter.allowedUnits = [.useKB,.useMB,.useGB,.useTB]; formatter.countStyle = .memory; let alert = UIAlertController(title: NSLocalizedString("Metadata storage", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("We are using %@ of storage.", comment: "alert body"), formatter.string(fromByteCount: Int64(MetadataCache.instance.size))), preferredStyle: .actionSheet); alert.addAction(UIAlertAction(title: NSLocalizedString("Everything", comment: "option to remove all data from local storage"), style: .destructive, handler: {(action) in DispatchQueue.global(qos: .background).async { MetadataCache.instance.clear(); } })); alert.addAction(UIAlertAction(title: NSLocalizedString("Older than 7 days", comment: "option to remove all data from local storage"), style: .destructive, handler: {(action) in DispatchQueue.global(qos: .background).async { MetadataCache.instance.clear(olderThan: Date().addingTimeInterval(7*24*60*60.0*(-1.0))); } })); alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); alert.popoverPresentationController?.sourceView = self.tableView; alert.popoverPresentationController?.sourceRect = self.tableView.rectForRow(at: indexPath); self.present(alert, animated: true, completion: nil); default: break; } } internal enum SettingsEnum: Int { case sharingViaHttpUpload case maxImagePreviewSize case clearDownloadStore case imageUploadQuality case videoUploadQuality case deviceMemoryUsage case clearMetadataStore } } ================================================ FILE: SiskinIM/settings/NotificationSettingsViewController.swift ================================================ // // NotificationSettingsViewController.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Combine class NotificationSettingsViewController: UITableViewController { private var cancellables: Set = []; private var items: [[SettingsEnum]] = [[.pushNotifications],[.notificationsFromUnknown]]; override func numberOfSections(in tableView: UITableView) -> Int { return items.count; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return items[section].count; } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { switch section { case 0: if UIApplication.shared.isRegisteredForRemoteNotifications { return NSLocalizedString("If enabled, you will receive notifications of new messages or calls even if SiskinIM is in background. SiskinIM servers will forward those notifications for you from XMPP servers.", comment: "push notifications option description"); } else { return NSLocalizedString("You need to allow application to show notifications and for background refresh.", comment: "push notifications not allowed warning") } case 1: return NSLocalizedString("Show notifications from people not in your contact list", comment: "notifications from unknown description"); default: return nil; } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let item = items[indexPath.section][indexPath.row]; switch item { case .pushNotifications: let cell = tableView.dequeueReusableCell(withIdentifier: "PushNotificationsTableViewCell", for: indexPath) as! SwitchTableViewCell; if anyAccountHasPush() && UIApplication.shared.isRegisteredForRemoteNotifications { cell.switchView.isEnabled = true; cell.bind({ c in c.assign(from: Settings.$enablePush.map({ $0 ?? false}).eraseToAnyPublisher()); c.sink(map: { $0 as Bool? }, to: \.enablePush, on: Settings); }) } else { cell.switchView.isOn = UIApplication.shared.isRegisteredForRemoteNotifications ? (Settings.enablePush ?? false) : false; cell.switchView.isEnabled = false; } return cell; case .notificationsFromUnknown: let cell = tableView.dequeueReusableCell(withIdentifier: "NotificationsFromUnknownTableViewCell", for: indexPath) as! SwitchTableViewCell; cell.switchView.isOn = Settings.notificationsFromUnknown; cancellables.removeAll(); cell.switchView.publisher(for: \.isOn).assign(to: \.notificationsFromUnknown, on: Settings).store(in: &cancellables); return cell; } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath as IndexPath, animated: true); } private func anyAccountHasPush() -> Bool { return !AccountManager.getAccounts().filter({ AccountSettings.knownServerFeatures(for: $0).contains(.push) }).isEmpty; } internal enum SettingsEnum { case pushNotifications case notificationsFromUnknown } } ================================================ FILE: SiskinIM/settings/OMEMOFingerprintsController.swift ================================================ // // OMEMOFingerprintsController.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import MartinOMEMO class OMEMOFingerprintsController: UITableViewController { var account: BareJID!; var localIdentity: Identity?; var otherIdentities: [Identity] = []; override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); let allIdentities = DBOMEMOStore.instance.identities(forAccount: account, andName: account.stringValue); let localDeviceId = Int32(bitPattern: AccountSettings.omemoRegistrationId(for: account) ?? 0); self.localIdentity = allIdentities.first(where: { (identity) -> Bool in return identity.address.deviceId == localDeviceId; }) self.otherIdentities = allIdentities.filter({ (identity) -> Bool in return identity.address.deviceId != localDeviceId; }); } override func numberOfSections(in tableView: UITableView) -> Int { return 2; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if section == 0 { return localIdentity != nil ? 1 : 0; } else { return otherIdentities.count; } } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { switch section { case 0: return NSLocalizedString("Fingerprint of this device", comment: "section label"); default: return NSLocalizedString("Other devices fingerprints", comment: "section label"); } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch indexPath.section { case 0: let cell = tableView.dequeueReusableCell(withIdentifier: "OMEMOLocalIdentityCell", for: indexPath); (cell.contentView.subviews[1] as? UILabel)?.text = String.localizedStringWithFormat(NSLocalizedString("Device: %@", comment: "label for omemo device id"), "\(localIdentity?.address.deviceId ?? -1)"); (cell.contentView.subviews[0] as? UILabel)?.text = preetify(fingerprint: localIdentity?.fingerprint); return cell; default: let identity = self.otherIdentities[indexPath.row]; let cell = tableView.dequeueReusableCell(withIdentifier: "OMEMORemoteIdentityCell", for: indexPath) as! OMEMOIdentityTableViewCell; cell.deviceLabel?.text = String.localizedStringWithFormat(NSLocalizedString("Device: %@", comment: "label for omemo device id"), "\(identity.address.deviceId)"); cell.identityLabel.text = preetify(fingerprint: identity.fingerprint); cell.trustSwitch.isEnabled = identity.status.isActive; cell.trustSwitch.isOn = identity.status.trust == .trusted || identity.status.trust == .undecided; let account = self.account!; cell.valueChangedListener = { (sender) in _ = DBOMEMOStore.instance.setStatus(identity.status.toTrust(sender.isOn ? .trusted : .compromised), forIdentity: identity.address, andAccount: account); } return cell; } } override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard indexPath.section == 1 else { return nil; } let account = self.account!; let identity = self.otherIdentities[indexPath.row]; return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { _ in return UIMenu(title: "", children: [ UIAction(title: NSLocalizedString("Delete", comment: "button label"), image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [weak self] action in guard let omemoModule = XmppService.instance.getClient(for: account)?.module(.omemo) else { return; } omemoModule.removeDevices(withIds: [identity.address.deviceId]); self?.otherIdentities.remove(at: indexPath.row); self?.tableView.reloadData(); }) ]) }); } func preetify(fingerprint tmp: String?) -> String? { guard var fingerprint = tmp else { return nil; } fingerprint = String(fingerprint.dropFirst(2)); var idx = fingerprint.startIndex; for _ in 0..<(fingerprint.count / 8) { idx = fingerprint.index(idx, offsetBy: 8); fingerprint.insert(" ", at: idx); idx = fingerprint.index(after: idx); } return fingerprint; } } ================================================ FILE: SiskinIM/settings/RegisterAccountController.swift ================================================ // // RegisterAccountController.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import UIKit import Martin import Combine class RegisterAccountController: DataFormController { @IBOutlet var nextButton: UIBarButtonItem! var domain: String? = nil; var domainFieldValue: String? = nil; let trustedServers = [ "tigase.im", "sure.im", "jabber.today" ]; var task: InBandRegistrationModule.AccountRegistrationTask?; var activityIndicator: UIActivityIndicatorView!; var onAccountAdded: (() -> Void)?; private var lockAccount: Bool = false; var account: BareJID? = nil; var password: String? = nil; var preauth: String? = nil; override func viewDidLoad() { super.viewDidLoad(); passwordSuggestNew = false; } override func viewWillAppear(_ animated: Bool) { if let account = self.account { self.lockAccount = self.account?.localPart != nil; DispatchQueue.main.async { self.updateDomain(account.domain); } } super.viewWillAppear(animated); } override func viewWillDisappear(_ animated: Bool) { onAccountAdded = nil; super.viewWillDisappear(animated); } func updateDomain(_ newValue: String?) { if newValue != nil && !newValue!.isEmpty && domain != newValue { nextButton.isEnabled = false; nextButton.title = NSLocalizedString("Register", comment: "button label"); let count = self.numberOfSections(in: tableView); self.domain = newValue; tableView.deleteSections(IndexSet(0.. Int { guard domain != nil else { return 2; } return super.numberOfSections(in: tableView); } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { guard domain != nil else { switch section { case 0: return 1; default: return trustedServers.count; } } return super.tableView(tableView, numberOfRowsInSection: section); } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { guard domain != nil else { switch section { case 0: return NSLocalizedString("Preferred domain name", comment: "section label"); case 1: return NSLocalizedString("Trusted servers", comment: "section label"); default: return ""; } } return super.tableView(tableView, titleForHeaderInSection: section); } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard domain != nil else { switch indexPath.section { case 0: let cell = tableView.dequeueReusableCell(withIdentifier: "AccountDomainTableViewCell", for: indexPath) as! AccountDomainTableViewCell; cell.domainField.addTarget(self, action: #selector(domainFieldChanged(domainField:)), for: .editingChanged); return cell; default: let cell = tableView.dequeueReusableCell(withIdentifier: "ServerSelectorTableViewCell", for: indexPath) as! ServerSelectorTableViewCell; cell.serverDomain.text = trustedServers[indexPath.row]; return cell; } } let cell = super.tableView(tableView, cellForRowAt: indexPath); if #available(iOS 11.0, *) { let fieldName = form!.visibleFieldNames[indexPath.row]; if fieldName == "username", let c = cell as? TextSingleFieldCell { c.uiTextField.isEnabled = !self.lockAccount; c.uiTextField?.textContentType = .username; } } return cell; } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { guard domain != nil else { switch section { case 0: return NSLocalizedString("If you don't know any XMPP server domain names, then select one of our trusted servers.", comment: "section footer") default: return nil; } } return nil; } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard domain != nil else { tableView.deselectRow(at: indexPath, animated: false); if indexPath.section == 1 { DispatchQueue.main.async { self.updateDomain(self.trustedServers[indexPath.row]); } } return; } super.tableView(tableView, didSelectRowAt: indexPath); } func saveAccount(acceptedCertificate: SslCertificateInfo?) { guard let jid = self.account else { return; } var account = AccountManager.getAccount(for: jid) ?? AccountManager.Account(name: jid); account.acceptCertificate(acceptedCertificate); var cancellables: Set = []; do { account.password = self.password!; try AccountManager.save(account: account); self.onAccountAdded?(); self.dismissView(); (UIApplication.shared.delegate as? AppDelegate)?.showSetup(value: false); } catch { cancellables.removeAll(); let alert = UIAlertController(title: NSLocalizedString("Error", comment: "alert title"), message: NSLocalizedString("It was not possible to save account details", comment: "alert title"), preferredStyle: .alert); self.present(alert, animated: true, completion: nil); } } func retrieveRegistrationForm(domain: String) { let onForm = {(form: JabberDataElement, bob: [BobData], task: InBandRegistrationModule.AccountRegistrationTask)->Void in DispatchQueue.main.async { self.nextButton.isEnabled = true; self.hideIndicator(); if let accountField = form.getField(named: "username") as? TextSingleField, accountField.value?.isEmpty ?? true { accountField.value = self.account?.localPart; } self.bob = bob; self.form = form; self.tableView.insertSections(IndexSet(0..<(form.visibleFieldNames.count + 1)), with: .fade); } }; let client: XMPPClient? = nil; self.task = InBandRegistrationModule.AccountRegistrationTask(client: client, domainName: domain, preauth: self.preauth, onForm: onForm, sslCertificateValidator: nil, onCertificateValidationError: self.onCertificateError, completionHandler: { result in switch result { case .success: let certData: SslCertificateInfo? = self.task?.getAcceptedCertificate(); DispatchQueue.main.async { self.saveAccount(acceptedCertificate: certData); } case .failure(let error): self.onRegistrationError(error); } }); } func onRegistrationError(_ error: XMPPError) { DispatchQueue.main.async { self.nextButton.isEnabled = true; self.hideIndicator(); } var msg = error.message; if msg == nil || msg == "Unsuccessful registration attempt" { switch error.errorCondition { case .feature_not_implemented: msg = NSLocalizedString("Registration is not supported by this server", comment: "account registration error"); case .not_acceptable, .not_allowed: msg = NSLocalizedString("Provided values are not acceptable", comment: "account registration error"); case .conflict: msg = NSLocalizedString("User with provided username already exists", comment: "account registration error"); case .service_unavailable: msg = NSLocalizedString("Service is not available at this time.", comment: "account registration error") default: msg = String.localizedStringWithFormat(NSLocalizedString("Server returned an error: %@", comment: "account registration error"), error.localizedDescription); } } var handler: ((UIAlertAction?)->Void)? = nil; switch error { case .feature_not_implemented, .service_unavailable(_): handler = {(action)->Void in self.dismissView(); }; default: break; } let alert = UIAlertController(title: NSLocalizedString("Registration failure", comment: "alert title"), message: msg, preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: handler)); DispatchQueue.main.async { self.present(alert, animated: true, completion: nil); } } func onCertificateError(certData: SslCertificateInfo, accepted: @escaping ()->Void) { let alert = CertificateErrorAlert.create(domain: domain!, certData: certData, onAccept: accepted, onDeny: { self.nextButton.isEnabled = true; self.hideIndicator(); self.dismissView(); }); DispatchQueue.main.async { self.present(alert, animated: true, completion: nil); } } @objc func domainFieldChanged(domainField: UITextField) { self.domainFieldValue = domainField.text; } @IBAction func cancelButtonClicked(_ sender: UIBarButtonItem) { dismissView(); } @objc func dismissView() { task?.cancel(); task = nil; if self.view.window?.rootViewController is SetupViewController { navigationController?.dismiss(animated: true, completion: nil); } else { let newController = navigationController?.popViewController(animated: true); if newController == nil || newController != self { let emptyDetailController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "emptyDetailViewController"); self.showDetailViewController(emptyDetailController, sender: self); } } } func showIndicator() { if activityIndicator != nil { hideIndicator(); } activityIndicator = UIActivityIndicatorView(style: .medium); activityIndicator?.center = CGPoint(x: view.frame.width/2, y: view.frame.height/2); activityIndicator!.isHidden = false; activityIndicator!.startAnimating(); view.addSubview(activityIndicator!); view.bringSubviewToFront(activityIndicator!); } func hideIndicator() { activityIndicator?.stopAnimating(); activityIndicator?.removeFromSuperview(); activityIndicator = nil; } } ================================================ FILE: SiskinIM/settings/ServerFeaturesViewController.swift ================================================ // // ServerFeaturesViewController.swift // // Siskin IM // Copyright (C) 2018 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine class ServerFeaturesViewController: UITableViewController { var client: XMPPClient!; private var features: [Feature] = []; private var cancellables: Set = []; override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); let allFeatures = loadFeatures(); client.module(.disco).$serverDiscoResult.receive(on: DispatchQueue.main).map({ it -> [Feature] in return allFeatures.filter({ $0.matches(it.features) }); }).sink(receiveValue: { [weak self] features in self?.features = features; self?.tableView.reloadData(); }).store(in: &cancellables); } override func numberOfSections(in tableView: UITableView) -> Int { return 1; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return features.count; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "StreamFeatureCell", for: indexPath); let feature = features[indexPath.row]; cell.textLabel?.text = feature.xep + ": " + feature.name; cell.detailTextLabel?.text = feature.description; return cell; } fileprivate func loadFeatures() -> [Feature] { guard let path = Bundle.main.path(forResource: "server_features_list", ofType: "xml") else { return []; } guard let str = try? String(contentsOfFile: path) else { return []; } guard let parent = Element.from(string: str) else { return []; } return parent.mapChildren(transform: Feature.init(from:)); } class Feature { let id: String?; let xep: String; let name: String; let description: String?; convenience init?(from el: Element) { guard let xep = el.findChild(name: "xep")?.value, let name = el.findChild(name: "name")?.value else { return nil; } self.init(id: el.getAttribute("id"), xep: xep, name: name, description: el.findChild(name: "description")?.value); } init(id: String?, xep: String, name: String, description: String?) { self.id = id; self.xep = xep; self.name = name; self.description = description; } func matches(_ features: [String]) -> Bool { guard let id = self.id else { return false; } if id.last == "*" { let prefix = id.prefix(upTo: id.index(before: (id.endIndex))); return features.contains(where: { (feature) -> Bool in return feature.starts(with: prefix); }) } return features.contains(id); } } } ================================================ FILE: SiskinIM/settings/ServerSelectorTableViewCell.swift ================================================ // // ServerSelectorTableViewCell.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import UIKit class ServerSelectorTableViewCell: UITableViewCell { @IBOutlet var serverDomain: UILabel! override func awakeFromNib() { super.awakeFromNib(); } } ================================================ FILE: SiskinIM/settings/SetAccountSettingsController.swift ================================================ // // SetAccountSettingsController.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Combine import Martin class SetAccountSettingsController: UITableViewController { var client: XMPPClient?; var sections: [Section] = [.accountName]; @Published private var enableMAM: Bool = true; @Published private var initialSyncMAM: SyncPeriod = .month; var completionHandler: (()->Void)?; private var activityIndicator: UIActivityIndicatorView?; override func viewDidLoad() { super.viewDidLoad(); activityIndicator = UIActivityIndicatorView(style: .large); activityIndicator?.translatesAutoresizingMaskIntoConstraints = false; activityIndicator?.hidesWhenStopped = true; if let view = activityIndicator { self.view.addSubview(view); NSLayoutConstraint.activate([ view.centerYAnchor.constraint(equalTo: self.view.centerYAnchor), view.centerXAnchor.constraint(equalTo: self.view.centerXAnchor) ]) } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); } override func numberOfSections(in tableView: UITableView) -> Int { return sections.count; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return 1; } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { let s = self.sections[section]; switch s { case .accountName: return NSLocalizedString("For account", comment: "section label") case .mamEnable: return NSLocalizedString("Message synchronization", comment: "section label") case .mamSyncInitial: return NSLocalizedString("Initial synchronization", comment: "section label") } } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { let s = self.sections[section]; switch s { case .accountName: return ""; case .mamEnable: return NSLocalizedString("Enabling message synchronization will enable message archiving on the server", comment: "option description") case .mamSyncInitial: return NSLocalizedString("Large value may increase inital synchronization time", comment: "option description"); } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let section = sections[indexPath.section]; switch section { case .accountName: let cell = tableView.dequeueReusableCell(withIdentifier: "AccountName", for: indexPath); cell.textLabel?.text = client?.userBareJid.description; return cell; case .mamEnable: let cell = tableView.dequeueReusableCell(withIdentifier: "MAMEnable", for: indexPath) as! MAMEnable; cell.bind({ c in c.assign(from: $enableMAM.eraseToAnyPublisher()); c.sink(to: \.enableMAM, on: self); }) return cell; case .mamSyncInitial: let cell = tableView.dequeueReusableCell(withIdentifier: "MAMInitialSync", for: indexPath) as! EnumTableViewCell; cell.bind({ c in c.assign(from: $initialSyncMAM.map({ $0.description }).eraseToAnyPublisher()); }) return cell; } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let section = sections[indexPath.section]; switch section { case .mamSyncInitial: let controller = TablePickerViewController(style: .grouped, message: NSLocalizedString("Select period of messages to be synchronized", comment: "selection description"), options: [.week,.twoWeeks,.month,.quarter,.year], value: initialSyncMAM); controller.sink(to: \.initialSyncMAM, on: self); self.navigationController?.pushViewController(controller, animated: true); default: break; } } @IBAction func skipClicked(_ sender: Any) { completionHandler?(); NewFeaturesDetector.instance.showNext(fromController: true); // } else { // self.navigationController?.dismiss(animated: true, completion: nil); // (UIApplication.shared.delegate as? AppDelegate)?.showSetup(value: false); // self.completionHandler?(); // } } @IBAction func doneClicked(_ sender: Any) { if let client = self.client { setInProgress(value: true); let group = DispatchGroup(); let since = Date().addingTimeInterval(-1 * initialSyncMAM.timeInterval); DBChatHistorySyncStore.instance.addSyncPeriod(.init(account: client.userBareJid, from: since, after: nil, to: nil)); MessageEventHandler.syncMessagePeriods(for: client); group.enter(); var errors: [XMPPError] = []; client.module(.mam).retrieveSettings(completionHandler: { result in switch result { case .success(let settings): var tmp = settings; tmp.defaultValue = self.enableMAM ? .always : .never; client.module(.mam).updateSettings(settings: tmp, completionHandler: { result in switch result { case .success(_): break; case .failure(let error): errors.append(error); } group.leave(); }) case .failure(let error): errors.append(error); group.leave(); } }) group.notify(queue: DispatchQueue.main, execute: { // guard errors.isEmpty else { // return; // } self.setInProgress(value: false); self.completionHandler?(); NewFeaturesDetector.instance.showNext(fromController: true); }) } else { completionHandler?(); NewFeaturesDetector.instance.showNext(fromController: true); // self.navigationController?.dismiss(animated: true, completion: nil); // (UIApplication.shared.delegate as? AppDelegate)?.showSetup(value: false); // self.completionHandler?(); } } private func setInProgress(value: Bool) { if value { self.navigationItem.leftBarButtonItem?.isEnabled = false; self.navigationItem.rightBarButtonItem?.isEnabled = false; activityIndicator?.startAnimating(); } else { self.navigationItem.leftBarButtonItem?.isEnabled = true; self.navigationItem.rightBarButtonItem?.isEnabled = true; activityIndicator?.stopAnimating(); } } enum Section { case accountName case mamEnable case mamSyncInitial } enum SyncPeriod: CustomStringConvertible { case week case twoWeeks case month case quarter case year var description: String { switch self { case .week: return NSLocalizedString("Week", comment: "synchronization period value") case .twoWeeks: return NSLocalizedString("Two weeks", comment: "synchronization period value") case .month: return NSLocalizedString("Month", comment: "synchronization period value") case .quarter: return NSLocalizedString("Quarter", comment: "synchronization period value") case .year: return NSLocalizedString("Year", comment: "synchronization period value") } } var timeInterval: TimeInterval { let day: Double = 3600 * 24; switch self { case .week: return 7 * day; case .twoWeeks: return 14 * day; case .month: return 31 * day; case .quarter: return (366 / 4.0) * day; case .year: return 366 * day; } } } } class MAMEnable: SwitchTableViewCell { } class MAMInitialSync: EnumTableViewCell { } ================================================ FILE: SiskinIM/settings/SettingsViewController.swift ================================================ // // SettingsViewController.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine class SettingsViewController: UITableViewController { var statusNames: [Presence.Show: String] = [ .chat : NSLocalizedString("Chat", comment: "presence status"), .online : NSLocalizedString("Online", comment: "presence status"), .away : NSLocalizedString("Away", comment: "presence status"), .xa : NSLocalizedString("Extended away", comment: "presence status"), .dnd : NSLocalizedString("Do not disturb", comment: "presence status"), ]; override func viewDidLoad() { self.appIcon = AppIcon(rawValue: UIApplication.shared.alternateIconName ?? "") ?? .default; super.viewDidLoad(); } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); tableView.reloadData(); } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated); } override func numberOfSections(in tableView: UITableView) -> Int { return 4; } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { switch section { case 0: return NSLocalizedString("Accounts", comment: "section label"); case 1: return NSLocalizedString("Status", comment: "section label"); case 2: return NSLocalizedString("Settings", comment: "section label"); case 3: return "" default: return ""; } } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { return nil; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch section { case 0: return AccountManager.getAccounts().count + 1; case 1: return 2; case 2: return SettingsGroup.groups.count; case 3: return 2; default: return 0; } } private var statusMessageCancellable: AnyCancellable?; private var statusTypeCancellable1: AnyCancellable?; private var statusTypeCancellable2: AnyCancellable?; enum AppIcon: String, CustomStringConvertible { case `default` = "AppIcon" case `simple` = "AppIcon-Simple" var description: String { switch self { case .default: return NSLocalizedString("Default", comment: "App icon") case .simple: return NSLocalizedString("Simple", comment: "App icon") } } } @Published private var appIcon: AppIcon = .default; override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { if (indexPath.section == 0) { let cellIdentifier = "AccountTableViewCell"; let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as! AccountTableViewCell; cell.accessoryType = .disclosureIndicator; let accounts = AccountManager.getAccounts(); if accounts.count > indexPath.row { cell.avatarStatusView.isHidden = false; cell.set(account: accounts[indexPath.row]); if AccountSettings.lastError(for :accounts[indexPath.row]) != nil { cell.avatarStatusView.statusImageView.image = UIImage(systemName: "xmark.circle.fill")!; } cell.avatarStatusView.updateCornerRadius(); } else { cell.nameLabel.text = NSLocalizedString("Add account", comment: "cell label"); cell.descriptionLabel.text = NSLocalizedString("Create new or add existing account", comment: "cell sublabel"); cell.avatarStatusView.avatarImageView.image = UIImage(systemName: "plus.circle.fill")?.withTintColor(UIColor(named: "tintColor")!, renderingMode: .alwaysOriginal); cell.avatarStatusView.statusImageView.isHidden = true; } return cell; } else if (indexPath.section == 1) { if indexPath.row == 1 { let cell = tableView.dequeueReusableCell(withIdentifier: "StatusTableViewCell", for: indexPath); let label = cell.viewWithTag(1)! as! UILabel; self.statusMessageCancellable = Settings.$statusMessage.assign(to: \.text, on: label); return cell; } else { let cell = tableView.dequeueReusableCell(withIdentifier: "StatusTypeSettingsViewCell", for: indexPath); self.statusTypeCancellable1 = Settings.$statusType.map({ [weak self] v in self?.getStatusIcon(type: v) }).sink(receiveValue: { [weak cell] image in if image == nil { (cell?.contentView.subviews[0] as? UIImageView)?.isHidden = true; } else { (cell?.contentView.subviews[0] as? UIImageView)?.image = image; (cell?.contentView.subviews[0] as? UIImageView)?.isHidden = false; } }); self.statusTypeCancellable2 = Settings.$statusType.map({ [weak self] type in if let value = type { return self?.statusNames[value]; } else { return NSLocalizedString("Automatic", comment: "presence status"); } }).sink(receiveValue: { [weak cell] name in (cell?.contentView.subviews[1] as? UILabel)?.text = name; }); cell.accessoryType = .disclosureIndicator; return cell; } } else if (indexPath.section == 2) { switch SettingsGroup.groups[indexPath.row] { case .appearance: let cell = tableView.dequeueReusableCell(withIdentifier: "AppearanceViewCell", for: indexPath) as! EnumTableViewCell; cell.bind({ cell in cell.assign(from: Settings.$appearance.map({ $0.description as String? }).eraseToAnyPublisher()); }) cell.accessoryType = .disclosureIndicator; return cell; case .icon: let cell = tableView.dequeueReusableCell(withIdentifier: "AppIconCellView", for: indexPath) as! EnumTableViewCell; cell.bind({ cell in cell.assign(from: self.$appIcon.map({ $0.description }).eraseToAnyPublisher()); }) cell.accessoryType = .disclosureIndicator; return cell; case .chat: let cell = tableView.dequeueReusableCell(withIdentifier: "ChatSettingsViewCell", for: indexPath); cell.accessoryType = .disclosureIndicator; return cell; case .contacts: let cell = tableView.dequeueReusableCell(withIdentifier: "ContactsSettingsViewCell", for: indexPath); cell.accessoryType = .disclosureIndicator; return cell; case .notifications: let cell = tableView.dequeueReusableCell(withIdentifier: "NotificationSettingsViewCell", for: indexPath); cell.accessoryType = .disclosureIndicator; return cell; case .media: let cell = tableView.dequeueReusableCell(withIdentifier: "MediaSettingsViewCell", for: indexPath); cell.accessoryType = .disclosureIndicator; return cell; case .experimental: let cell = tableView.dequeueReusableCell(withIdentifier: "ExperimentalSettingsViewCell", for: indexPath); cell.accessoryType = .disclosureIndicator; return cell; } } else { switch AboutGroup.groups[indexPath.row] { case .getInTouch: let cell = tableView.dequeueReusableCell(withIdentifier: "GetInTouchSettingsViewCell", for: indexPath); return cell; case .aboutTheApp: let cell = tableView.dequeueReusableCell(withIdentifier: "AboutSettingsViewCell", for: indexPath); return cell; } } } override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { if let accountCell = cell as? AccountTableViewCell { accountCell.avatarStatusView.updateCornerRadius(); } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath as IndexPath, animated: true); if indexPath.section == 0 { let accounts = AccountManager.getAccounts(); if indexPath.row == accounts.count { let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet); alert.addAction(UIAlertAction(title: NSLocalizedString("Create new", comment: "button label"), style: .default, handler: { (action) in self.showAddAccount(register: true); })); alert.addAction(UIAlertAction(title: NSLocalizedString("Add existing", comment: "button label"), style: .default, handler: { (action) in self.showAddAccount(register: false); })); alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); alert.popoverPresentationController?.sourceView = self.tableView; alert.popoverPresentationController?.sourceRect = self.tableView.rectForRow(at: indexPath); self.present(alert, animated: true, completion: nil); } else { // show edit account dialog let account = accounts[indexPath.row]; let accountSettingsController = AccountSettingsViewController.instantiate(fromAppStoryboard: .Account); accountSettingsController.hidesBottomBarWhenPushed = true; accountSettingsController.account = account; self.navigationController?.pushViewController(accountSettingsController, animated: true); } } else if indexPath.section == 1 { if indexPath.row == 0 { let alert = UIAlertController(title: NSLocalizedString("Select status", comment: "alert title"), message: nil, preferredStyle: .actionSheet); let options: [Presence.Show?] = [nil, .chat, .online, .away, .xa, .dnd]; for type in options { let name = type == nil ? NSLocalizedString("Automatic", comment: "presence automatic") : self.statusNames[type!]; let action = UIAlertAction(title: name, style: .default) { (a) in Settings.statusType = type; }; if type != nil { action.setValue(getStatusIcon(type: type!), forKey: "image") } alert.addAction(action); } let action = UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil); alert.addAction(action); alert.popoverPresentationController?.sourceView = self.tableView; alert.popoverPresentationController?.sourceRect = self.tableView.rectForRow(at: indexPath); self.present(alert, animated: true, completion: nil); } else if indexPath.row == 1 { let alert = UIAlertController(title: NSLocalizedString("Status", comment: "alert title"), message: NSLocalizedString("Enter status message", comment: "alert body"), preferredStyle: .alert); alert.addTextField(configurationHandler: { (textField) in textField.text = Settings.statusMessage; }) alert.addAction(UIAlertAction(title: NSLocalizedString("Set", comment: "button label"), style: .default, handler: { (action) -> Void in Settings.statusMessage = (alert.textFields![0] as UITextField).text; self.tableView.reloadData(); })); self.present(alert, animated: true, completion: nil); } } else if indexPath.section == 2 { switch SettingsGroup.groups[indexPath.row] { case .appearance: let controller = TablePickerViewController(style: .grouped, message: NSLocalizedString("Select appearance", comment: "selection information"), options: [.auto, .light, .dark], value: Settings.appearance); controller.sink(to: \.appearance, on: Settings); self.navigationController?.pushViewController(controller, animated: true); case .icon: let controller = TablePickerViewController(style: .grouped, message: NSLocalizedString("Select application icon", comment: "selection application icon information"), options: [.default,.simple], value: appIcon); controller.sink(receiveValue: { [weak self] value in self?.appIcon = value; let strValue = value == .default ? nil : value.rawValue; if UIApplication.shared.alternateIconName != strValue { UIApplication.shared.setAlternateIconName(strValue) { error in if error != nil { self?.appIcon = AppIcon(rawValue: UIApplication.shared.alternateIconName ?? "") ?? .default; } } } }) self.navigationController?.pushViewController(controller, animated: true); default: break; } } } override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { return false; } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { (segue.destination as? UINavigationController)?.visibleViewController?.hidesBottomBarWhenPushed = true; } func showAddAccount(register: Bool) { // show add account dialog if !register { let addAccountController = AddAccountController.instantiate(fromAppStoryboard: .Account); addAccountController.hidesBottomBarWhenPushed = true; self.navigationController?.pushViewController(addAccountController, animated: true); } else { let registerAccountController = RegisterAccountController.instantiate(fromAppStoryboard: .Account); registerAccountController.hidesBottomBarWhenPushed = true; self.navigationController?.pushViewController(registerAccountController, animated: true); } } private func getStatusIcon(type: Presence.Show?) -> UIImage? { guard let show = type else { return nil; } return AvatarStatusView.getStatusImage(show); } @IBAction func closeClicked(_ sender: Any) { self.dismiss(animated: true, completion: nil); } enum SettingsGroup { case appearance case icon case chat case contacts case notifications case media case experimental //case about static let groups: [SettingsGroup] = [.appearance, .icon, .chat, .contacts, .notifications, .media, .experimental]; } enum AboutGroup { case getInTouch case aboutTheApp static let groups: [AboutGroup] = [.getInTouch, .aboutTheApp] } } ================================================ FILE: SiskinIM/settings/SetupViewController.swift ================================================ // // SetupViewController.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class SetupViewController: UIViewController { @IBOutlet var appLogoView: UIImageView!; @IBOutlet var titleView: UILabel!; @IBOutlet var subtitleView: UILabel!; @IBOutlet var createAccountBtn: UIButton! @IBOutlet var existingAccountBtn: UIButton! override func viewDidLoad() { createAccountBtn.layer.borderWidth = 1; //createAccountBtn.layer.cornerRadius = createAccountBtn.frame.height / 2; createAccountBtn.layer.borderColor = UIColor.white.cgColor; NotificationCenter.default.addObserver(self, selector: #selector(orientationChanged(_:)), name: UIDevice.orientationDidChangeNotification, object: nil); } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); appLogoView.layer.masksToBounds = true; orientationChanged(); } @objc func orientationChanged(_ notification: Notification) { orientationChanged(); } func orientationChanged() { createAccountBtn.layer.cornerRadius = createAccountBtn.frame.height / 2; appLogoView.layer.cornerRadius = appLogoView.frame.width / 8; } @IBAction func createAccountBtnClicked(_ sender: AnyObject) { let addAccountController = RegisterAccountController.instantiate(fromAppStoryboard: .Account); addAccountController.hidesBottomBarWhenPushed = true; let navigationController = UINavigationController(rootViewController: addAccountController); self.showDetailViewController(navigationController, sender: self); } @IBAction func existingAccountBtnClicked(_ sender: AnyObject) { let addAccountController = AddAccountController.instantiate(fromAppStoryboard: .Account); addAccountController.hidesBottomBarWhenPushed = true; let navigationController = UINavigationController(rootViewController: addAccountController); self.showDetailViewController(navigationController, sender: self); } } ================================================ FILE: SiskinIM/settings/server_features_list.xml ================================================ XEP-0001 XMPP Extension Protocols Procedural Active 2016-11-16 XEP-0002 Special Interest Groups (SIGs) Procedural Active 2002-01-11 XEP-0003 Proxy Accept Socket Service (PASS) Historical Obsolete 2009-06-03 XEP-0004 Data Forms Standards Track Final 2007-08-13 XEP-0005 Jabber Interest Groups Informational Obsolete 2002-05-08 XEP-0006 Profiles SIG Formation Obsolete 2002-05-08 XEP-0007 Conferencing SIG SIG Proposal Obsolete 2002-05-08 XEP-0008 IQ-Based Avatars Historical Deferred 2005-06-16 XEP-0009 Jabber-RPC Standards Track Final 2011-11-10 XEP-0010 Whiteboarding SIG SIG Formation Obsolete 2002-05-08 XEP-0011 Jabber Browsing Historical Obsolete 2009-06-03 XEP-0012 Last Activity Standards Track Final 2008-11-26 XEP-0013 Flexible Offline Message Retrieval Standards Track Draft 2005-07-14 XEP-0014 Message Tone Standards Track Rejected 2002-01-16 XEP-0015 Account Transfer Standards Track Rejected 2002-04-18 XEP-0016 Privacy Lists Standards Track Deprecated 2017-05-20 XEP-0017 Naive Packet Framing Protocol Informational Rejected 2002-02-19 XEP-0018 Invisible Presence Informational Rejected 2003-09-26 XEP-0019 Streamlining the SIGs Procedural Active 2002-03-20 XEP-0020 Feature Negotiation Standards Track Deprecated 2018-03-07 XEP-0021 Jabber Event Notification Service (ENS) Standards Track Retracted 2003-04-22 XEP-0022 Message Events Historical Obsolete 2009-05-27 XEP-0023 Message Expiration Historical Obsolete 2009-06-03 XEP-0024 Publish/Subscribe Standards Track Retracted 2003-04-22 XEP-0025 Jabber HTTP Polling Historical Obsolete 2009-06-03 XEP-0026 Internationalization (I18N) Standards Track Retracted 2003-11-05 XEP-0027 Current Jabber OpenPGP Usage Historical Obsolete 2014-03-14 XEP-0028 No Such XEP Informational Retracted 2001-08-20 XEP-0029 Definition of Jabber Identifiers (JIDs) Standards Track Retracted 2003-10-03 XEP-0030 Service Discovery Standards Track Final 2017-10-03 XEP-0031 A Framework For Securing Jabber Conversations Standards Track Deferred 2002-07-09 XEP-0032 Jabber URI Scheme Standards Track Retracted 2003-09-02 XEP-0033 Extended Stanza Addressing Standards Track Draft 2017-01-11 XEP-0034 SASL Integration Standards Track Retracted 2003-11-05 XEP-0035 SSL/TLS Integration Standards Track Retracted 2003-11-05 XEP-0036 Pub-Sub Subscriptions Standards Track Retracted 2003-04-22 XEP-0037 DSPS - Data Stream Proxy Service Standards Track Rejected 2016-10-04 XEP-0038 Icon Styles Standards Track Deferred 2003-06-02 XEP-0039 Statistics Gathering Standards Track Deferred 2002-11-05 XEP-0040 Jabber Robust Publish-Subscribe Standards Track Retracted 2004-07-26 XEP-0041 Reliable Entity Link Standards Track Retracted 2003-09-30 XEP-0042 Jabber OOB Broadcast Service (JOBS) Standards Track Retracted 2003-04-11 XEP-0043 Jabber Database Access Standards Track Retracted 2003-10-20 XEP-0044 Full Namespace Support for XML Streams Standards Track Deferred 2002-08-26 XEP-0045 Multi-User Chat Standards Track Draft 2018-07-31 XEP-0046 DTCP Standards Track Retracted 2003-04-11 XEP-0047 In-Band Bytestreams Standards Track Final 2012-06-22 XEP-0048 Bookmarks Standards Track Draft 2007-11-07 XEP-0049 Private XML Storage Historical Active 2004-03-01 XEP-0050 Ad-Hoc Commands Standards Track Draft 2016-12-03 XEP-0051 Connection Transfer Standards Track Deferred 2009-07-07 XEP-0052 File Transfer Standards Track Retracted 2003-09-30 XEP-0053 XMPP Registrar Function Procedural Active 2016-12-01 XEP-0054 vcard-temp Historical Active 2008-07-16 XEP-0055 Jabber Search Historical Active 2009-09-15 XEP-0056 Business Data Interchange Standards Track Deferred 2002-11-18 XEP-0057 Extended Roster Standards Track Retracted 2003-04-28 XEP-0058 Multi-User Text Editing Standards Track Deferred 2002-11-12 XEP-0059 Result Set Management Standards Track Draft 2006-09-20 XEP-0060 Publish-Subscribe Standards Track Draft 2018-05-14 XEP-0061 Shared Notes Informational Deferred 2003-09-30 XEP-0062 Packet Filtering Informational Deferred 2003-09-30 XEP-0063 Basic Filtering Operations Informational Deferred 2003-09-30 XEP-0064 XPath Filtering Informational Deferred 2003-09-30 XEP-0065 SOCKS5 Bytestreams Standards Track Draft 2015-09-17 XEP-0065 Out of Band Data Standards Track Draft 2006-08-15 XEP-0066 Out of Band Data Standards Track Draft 2006-08-16 XEP-0067 Stock Data Transmission Standards Track Deferred 2003-07-19 XEP-0068 Field Standardization for Data Forms Informational Active 2012-05-28 XEP-0069 Compliance SIG SIG Formation Deferred 2003-01-29 XEP-0070 Verifying HTTP Requests via XMPP Standards Track Draft 2016-12-09 XEP-0071 XHTML-IM Standards Track Deprecated 2018-03-08 XEP-0072 SOAP Over XMPP Standards Track Draft 2005-12-14 XEP-0073 Basic IM Protocol Suite Standards Track Obsolete 2007-10-30 XEP-0074 Simple Access Control Standards Track Retracted 2003-10-20 XEP-0075 Jabber Object Access Protocol (JOAP) Standards Track Deferred 2003-05-22 XEP-0076 Malicious Stanzas Humorous Active 2003-04-01 XEP-0077 In-Band Registration Standards Track Final 2012-01-25 XEP-0078 Non-SASL Authentication Standards Track Obsolete 2008-10-29 XEP-0079 Advanced Message Processing Standards Track Draft 2005-11-30 XEP-0080 User Location Standards Track Draft 2015-12-01 XEP-0081 Jabber MIME Type Standards Track Retracted 2005-07-19 XEP-0082 XMPP Date and Time Profiles Informational Active 2013-09-26 XEP-0083 Nested Roster Groups Informational Active 2004-10-11 XEP-0084 User Avatar Standards Track Draft 2016-07-09 XEP-0085 Chat State Notifications Standards Track Final 2009-09-23 XEP-0086 Error Condition Mappings Informational Deprecated 2004-02-17 XEP-0087 Stream Initiation Standards Track Retracted 2003-05-22 XEP-0088 Client Webtabs Informational Deferred 2004-03-14 XEP-0089 Generic Alerts Standards Track Deferred 2003-05-16 XEP-0090 Legacy Entity Time Historical Obsolete 2009-05-27 XEP-0091 Legacy Delayed Delivery Historical Obsolete 2009-05-27 XEP-0092 Software Version Standards Track Draft 2007-02-15 XEP-0093 Roster Item Exchange Historical Deprecated 2005-08-26 XEP-0094 Agent Information Historical Obsolete 2003-10-08 XEP-0095 Stream Initiation Standards Track Deprecated 2017-11-29 XEP-0096 SI File Transfer Standards Track Deprecated 2017-11-29 XEP-0097 iCal Envelope Standards Track Deferred 2003-06-10 XEP-0098 Enhanced Private XML Storage Standards Track Deferred 2003-06-25 XEP-0099 IQ Query Action Protocol Standards Track Deferred 2003-06-25 XEP-0100 Gateway Interaction Informational Active 2005-10-05 XEP-0101 HTTP Authentication using Jabber Tickets Standards Track Deferred 2004-01-18 XEP-0102 Security Extensions Standards Track Deferred 2003-06-25 XEP-0103 URL Address Information Standards Track Deferred 2004-01-20 XEP-0104 HTTP Scheme for URL Data Standards Track Deferred 2004-01-20 XEP-0105 Tree Transfer Stream Initiation Profile Standards Track Deferred 2003-09-22 XEP-0106 JID Escaping Standards Track Draft 2016-07-08 XEP-0107 User Mood Standards Track Draft 2018-03-13 XEP-0108 User Activity Standards Track Draft 2008-10-29 XEP-0109 Out-of-Office Messages Standards Track Deferred 2010-05-24 XEP-0110 Generic Maps Standards Track Deferred 2003-07-28 XEP-0111 A Transport for Initiating and Negotiating Sessions (TINS) Standards Track Retracted 2005-12-21 XEP-0112 User Physical Location Standards Track Obsolete 2004-10-12 XEP-0113 Simple Whiteboarding Informational Deferred 2003-09-07 XEP-0114 Jabber Component Protocol Historical Active 2012-01-25 XEP-0115 Entity Capabilities Standards Track Draft 2016-10-06 XEP-0116 Encrypted Session Negotiation Standards Track Deferred 2007-05-30 XEP-0117 Intermediate IM Protocol Suite Standards Track Obsolete 2007-10-30 XEP-0118 User Tune Standards Track Draft 2008-01-30 XEP-0119 Extended Presence Protocol Suite Standards Track Retracted 2006-08-08 XEP-0120 Infobits Standards Track Retracted 2004-01-22 XEP-0121 Dublin Core Infobits Mapping Informational Retracted 2003-12-15 XEP-0122 Data Forms Validation Standards Track Draft 2018-03-21 XEP-0123 Entity Metadata Standards Track Retracted 2003-12-16 XEP-0124 Bidirectional-streams Over Synchronous HTTP (BOSH) Standards Track Draft 2016-11-16 XEP-0125 vCard Infobits Mapping Informational Retracted 2003-12-15 XEP-0126 Invisibility Informational Deprecated 2005-08-19 XEP-0127 Common Alerting Protocol (CAP) Over XMPP Informational Active 2004-12-09 XEP-0128 Service Discovery Extensions Informational Active 2004-10-20 XEP-0129 WebDAV File Transfers Standards Track Deferred 2007-04-19 XEP-0130 Waiting Lists Historical Deprecated 2012-04-18 XEP-0131 Stanza Headers and Internet Metadata Standards Track Draft 2006-07-12 XEP-0132 Presence Obtained via Kinesthetic Excitation (POKE) Humorous Active 2004-04-01 XEP-0133 Service Administration Informational Active 2017-07-15 XEP-0134 XMPP Design Guidelines Informational Active 2004-12-09 XEP-0135 File Sharing Standards Track Deferred 2004-06-04 XEP-0136 Message Archiving Standards Track Deprecated 2017-11-15 XEP-0137 Publishing Stream Initiation Requests Standards Track Deprecated 2018-02-28 XEP-0138 Stream Compression Standards Track Final 2009-05-27 XEP-0139 Security SIG SIG Formation Retracted 2004-09-15 XEP-0140 Shared Groups Informational Retracted 2004-10-27 XEP-0141 Data Forms Layout Standards Track Draft 2005-05-12 XEP-0142 Workgroup Queues Standards Track Deferred 2005-05-09 XEP-0143 Guidelines for Authors of XMPP Extension Protocols Procedural Active 2016-12-02 XEP-0144 Roster Item Exchange Standards Track Draft 2017-11-28 XEP-0145 Annotations Historical Active 2006-03-23 XEP-0146 Remote Controlling Clients Informational Obsolete 2017-11-07 XEP-0147 XMPP URI Scheme Query Components Informational Active 2006-09-13 XEP-0148 Instant Messaging Intelligence Quotient (IM IQ) Humorous Active 2005-04-01 XEP-0149 Time Periods Informational Active 2006-01-24 XEP-0150 Use of Entity Tags in XMPP Extensions Informational Deferred 2005-08-09 XEP-0151 Virtual Presence Standards Track Deferred 2005-07-05 XEP-0152 Reachability Addresses Standards Track Draft 2014-02-25 XEP-0153 vCard-Based Avatars Historical Active 2018-02-26 XEP-0154 User Profile Standards Track Deferred 2008-04-18 XEP-0155 Stanza Session Negotiation Standards Track Draft 2016-01-20 XEP-0156 Discovering Alternative XMPP Connection Methods Standards Track Draft 2018-07-21 XEP-0157 Contact Addresses for XMPP Services Informational Active 2018-07-21 XEP-0158 CAPTCHA Forms Standards Track Draft 2008-09-03 XEP-0159 Spim-Blocking Control Standards Track Deferred 2006-07-11 XEP-0160 Best Practices for Handling Offline Messages Informational Active 2016-10-07 XEP-0161 Abuse Reporting Standards Track Deferred 2007-05-06 XEP-0162 Best Practices for Roster and Subscription Management Informational Deferred 2005-12-06 XEP-0163 Personal Eventing Protocol Standards Track Draft 2018-03-18 XEP-0164 vCard Filtering Standards Track Deferred 2005-11-16 XEP-0165 Best Practices to Discourage JID Mimicking Informational Deferred 2007-12-13 XEP-0166 Jingle Standards Track Draft 2016-05-17 XEP-0167 Jingle RTP Sessions Standards Track Draft 2016-07-08 XEP-0168 Resource Application Priority Standards Track Deferred 2008-09-26 XEP-0169 Twas The Night Before Christmas (Jabber Version) Humorous Active 2009-12-24 XEP-0170 Recommended Order of Stream Feature Negotiation Informational Active 2007-01-04 XEP-0171 Language Translation Standards Track Draft 2015-10-15 XEP-0172 User Nickname Standards Track Draft 2012-03-21 XEP-0173 Pubsub Subscription Storage Historical Deferred 2006-02-09 XEP-0174 Serverless Messaging Standards Track Final 2018-02-08 XEP-0175 Best Practices for Use of SASL ANONYMOUS Informational Active 2009-09-30 XEP-0176 Jingle ICE-UDP Transport Method Standards Track Draft 2009-06-10 XEP-0177 Jingle Raw UDP Transport Method Standards Track Draft 2009-12-23 XEP-0178 Best Practices for Use of SASL EXTERNAL with Certificates Informational Active 2011-05-25 XEP-0179 Jingle IAX Transport Method Standards Track Deferred 2006-03-23 XEP-0180 Jingle Video via RTP Standards Track Retracted 2008-06-04 XEP-0181 Jingle DTMF Standards Track Deferred 2009-10-02 XEP-0182 Application-Specific Error Conditions Procedural Active 2008-03-05 XEP-0183 Jingle Telepathy Transport Humorous Active 2006-04-01 XEP-0184 Message Delivery Receipts Standards Track Draft 2011-03-01 XEP-0185 Dialback Key Generation and Validation Informational Active 2007-02-15 XEP-0186 Invisible Command Standards Track Proposed 2017-11-29 XEP-0187 Offline Encrypted Sessions Standards Track Deferred 2007-05-30 XEP-0188 Cryptographic Design of Encrypted Sessions Informational Deferred 2007-05-30 XEP-0189 Public Key Publishing Standards Track Deferred 2010-07-15 XEP-0190 Best Practice for Closing Idle Streams Informational Obsolete 2012-03-06 XEP-0191 Blocking Command Standards Track Draft 2015-03-12 XEP-0192 Proposed Stream Feature Improvements Standards Track Obsolete 2012-02-08 XEP-0193 Proposed Resource Binding Improvements Standards Track Obsolete 2012-02-08 XEP-0194 User Chatting Standards Track Deferred 2008-09-25 XEP-0195 User Browsing Standards Track Deferred 2008-09-25 XEP-0196 User Gaming Standards Track Deferred 2008-09-25 XEP-0197 User Viewing Standards Track Deferred 2008-09-25 XEP-0198 Stream Management Standards Track Draft 2018-07-19 XEP-0199 XMPP Ping Standards Track Final 2009-06-03 XEP-0200 Stanza Encryption Standards Track Deferred 2007-05-30 XEP-0201 Best Practices for Message Threads Informational Active 2010-11-29 XEP-0202 Entity Time Standards Track Final 2009-09-11 XEP-0203 Delayed Delivery Standards Track Final 2009-09-15 XEP-0204 Collaborative Data Objects Standards Track Deferred 2007-01-17 XEP-0205 Best Practices to Discourage Denial of Service Attacks Informational Active 2009-01-07 XEP-0206 XMPP Over BOSH Standards Track Draft 2014-04-09 XEP-0207 XMPP Eventing via Pubsub Humorous Active 2007-04-01 XEP-0208 Bootstrapping Implementation of Jingle Informational Retracted 2009-01-06 XEP-0209 Metacontacts Standards Track Deferred 2007-04-10 XEP-0210 Requirements for Encrypted Sessions Standards Track Deferred 2007-05-30 XEP-0211 XMPP Basic Client 2008 Standards Track Obsolete 2007-07-11 XEP-0212 XMPP Basic Server 2008 Standards Track Obsolete 2007-07-11 XEP-0213 XMPP Intermediate IM Client 2008 Standards Track Obsolete 2007-07-11 XEP-0214 File Repository and Sharing Standards Track Deferred 2009-01-05 XEP-0215 External Service Discovery Standards Track Deferred 2015-10-20 XEP-0216 XMPP Intermediate IM Server 2008 Standards Track Obsolete 2007-07-11 XEP-0217 Simplified Encrypted Session Negotiation Standards Track Deferred 2007-05-30 XEP-0218 Bootstrapping Implementation of Encrypted Sessions Informational Deferred 2007-05-30 XEP-0219 Hop Check Standards Track Retracted 2008-06-12 XEP-0220 Server Dialback Standards Track Draft 2015-03-12 XEP-0221 Data Forms Media Element Standards Track Draft 2008-09-03 XEP-0222 Persistent Storage of Public Data via PubSub Informational Active 2008-09-08 XEP-0223 Persistent Storage of Private Data via PubSub Informational Active 2018-03-28 XEP-0224 Attention Standards Track Draft 2008-11-13 XEP-0225 Component Connections Standards Track Deferred 2008-10-06 XEP-0226 Message Stanza Profiles Informational Deferred 2008-11-05 XEP-0227 Portable Import/Export Format for XMPP-IM Servers Standards Track Draft 2010-03-12 XEP-0228 Requirements for Shared Editing Standards Track Deferred 2007-08-22 XEP-0229 Stream Compression with LZW Standards Track Draft 2007-09-26 XEP-0230 Service Discovery Notifications Standards Track Deferred 2016-10-04 XEP-0231 Bits of Binary Standards Track Draft 2008-09-03 XEP-0232 Software Information Standards Track Deferred 2009-02-26 XEP-0233 XMPP Server Registration for use with Kerberos V5 Standards Track Draft 2017-03-16 XEP-0234 Jingle File Transfer Standards Track Proposed 2017-08-24 XEP-0235 OAuth Over XMPP Standards Track Deferred 2009-03-24 XEP-0236 Abuse Reporting Standards Track Retracted 2008-05-09 XEP-0237 Roster Versioning Standards Track Obsolete 2012-02-08 XEP-0238 XMPP Protocol Flows for Inter-Domain Federation Informational Deferred 2008-03-31 XEP-0239 Binary XMPP Humorous Active 2008-04-01 XEP-0240 Auto-Discovery of JabberIDs Standards Track Deferred 2008-04-30 XEP-0241 Encryption of Archived Messages Standards Track Deferred 2008-04-30 XEP-0242 XMPP Client Compliance 2009 Standards Track Obsolete 2008-09-08 XEP-0243 XMPP Server Compliance 2009 Standards Track Obsolete 2008-09-08 XEP-0244 IO Data Standards Track Deferred 2008-06-18 XEP-0245 The /me Command Informational Active 2009-01-21 XEP-0246 End-to-End XML Streams Standards Track Deferred 2016-01-20 XEP-0247 Jingle XML Streams Standards Track Deferred 2009-02-20 XEP-0248 PubSub Collection Nodes Standards Track Deferred 2010-09-28 XEP-0249 Direct MUC Invitations Standards Track Draft 2011-09-22 XEP-0250 C2C Authentication Using TLS Standards Track Deferred 2008-09-08 XEP-0251 Jingle Session Transfer Standards Track Deferred 2009-10-05 XEP-0252 BOSH Script Syntax Historical Deferred 2008-10-31 XEP-0253 PubSub Chaining Standards Track Deferred 2009-11-18 XEP-0254 PubSub Queueing Standards Track Deferred 2008-11-13 XEP-0255 Location Query Standards Track Deferred 2009-04-09 XEP-0256 Last Activity in Presence Standards Track Draft 2009-09-15 XEP-0257 Client Certificate Management for SASL EXTERNAL Standards Track Deferred 2012-07-18 XEP-0258 Security Labels in XMPP Standards Track Draft 2013-04-08 XEP-0259 Message Mine-ing Standards Track Deferred 2009-01-21 XEP-0260 Jingle SOCKS5 Bytestreams Transport Method Standards Track Draft 2018-05-04 XEP-0261 Jingle In-Band Bytestreams Transport Method Standards Track Draft 2011-09-23 XEP-0262 Use of ZRTP in Jingle RTP Sessions Standards Track Draft 2011-06-15 XEP-0263 ECO-XMPP Humorous Active 2009-04-01 XEP-0264 Jingle Content Thumbnails Standards Track Deferred 2015-08-26 XEP-0265 Out-of-Band Stream Data Standards Track Deferred 2009-04-02 XEP-0266 Codecs for Jingle Audio Standards Track Draft 2013-03-01 XEP-0267 Server Buddies Standards Track Deferred 2012-05-29 XEP-0268 Incident Handling Standards Track Deferred 2012-05-29 XEP-0269 Jingle Early Media Standards Track Deferred 2009-05-19 XEP-0270 XMPP Compliance Suites 2010 Standards Track Obsolete 2017-01-28 XEP-0271 XMPP Nodes Informational Deferred 2009-06-26 XEP-0272 Multiparty Jingle (Muji) Standards Track Deferred 2009-09-11 XEP-0273 Stanza Interception and Filtering Technology (SIFT) Standards Track Deferred 2011-06-27 XEP-0274 Design Considerations for Digital Signatures in XMPP Informational Deferred 2011-01-28 XEP-0275 Entity Reputation Standards Track Deferred 2012-06-06 XEP-0276 Presence Decloaking Standards Track Deferred 2012-07-13 XEP-0277 Microblogging over XMPP Standards Track Deferred 2017-11-28 XEP-0278 Jingle Relay Nodes Standards Track Experimental 2017-09-14 XEP-0279 Server IP Check Standards Track Deferred 2013-04-17 XEP-0280 Message Carbons Standards Track Proposed 2017-02-16 XEP-0281 DMUC1: Distributed Multi-User Chat Standards Track Retracted 2010-07-20 XEP-0282 DMUC2: Distributed MUC Standards Track Deferred 2010-06-11 XEP-0283 Moved Standards Track Experimental 2018-08-06 XEP-0284 Shared XML Editing Standards Track Deferred 2010-07-02 XEP-0285 Encapsulating Digital Signatures in XMPP Standards Track Deferred 2011-01-12 XEP-0286 Mobile Considerations on LTE Networks Informational Active 2018-01-25 XEP-0287 Spim Markers and Reports Standards Track Deferred 2010-10-03 XEP-0287 Spim Markers and Reports Standards Track Deferred 2010-10-04 XEP-0288 Bidirectional Server-to-Server Connections Standards Track Draft 2016-10-17 XEP-0289 Federated MUC for Constrained Environments Standards Track Deferred 2012-05-29 XEP-0290 Encapsulated Digital Signatures in XMPP Standards Track Deferred 2011-01-28 XEP-0291 Service Delegation Standards Track Deferred 2011-01-26 XEP-0292 vCard4 Over XMPP Standards Track Deferred 2013-09-12 XEP-0293 Jingle RTP Feedback Negotiation Standards Track Draft 2015-08-11 XEP-0294 Jingle RTP Header Extensions Negotiation Standards Track Draft 2015-08-11 XEP-0295 JSON Encodings for XMPP Humorous Active 2011-04-01 XEP-0296 Best Practices for Resource Locking Informational Deferred 2011-08-18 XEP-0297 Stanza Forwarding Standards Track Draft 2013-10-02 XEP-0298 Delivering Conference Information to Jingle Participants (Coin) Standards Track Deferred 2015-07-02 XEP-0299 Codecs for Jingle Video Standards Track Deferred 2011-06-12 XEP-0300 Use of Cryptographic Hash Functions in XMPP Standards Track Experimental 2018-02-14 XEP-0301 In-Band Real Time Text Standards Track Draft 2013-10-08 XEP-0302 XMPP Compliance Suites 2012 Standards Track Obsolete 2011-07-21 XEP-0303 Commenting Standards Track Deferred 2011-07-28 XEP-0304 Whitespace Keepalive Negotiation Standards Track Deferred 2011-08-18 XEP-0305 XMPP Quickstart Standards Track Deferred 2013-03-01 XEP-0306 Extensible Status Conditions for Multi-User Chat Standards Track Deferred 2016-06-07 XEP-0307 Unique Room Names for Multi-User Chat Standards Track Deferred 2011-11-10 XEP-0308 Last Message Correction Standards Track Draft 2013-04-08 XEP-0309 Service Directories Standards Track Deferred 2012-05-29 XEP-0310 Presence State Annotations Standards Track Deferred 2012-01-10 XEP-0311 MUC Fast Reconnect Standards Track Deferred 2012-01-25 XEP-0312 PubSub Since Standards Track Deferred 2012-05-29 XEP-0313 Message Archive Management Standards Track Experimental 2018-07-16 XEP-0314 Security Labels in PubSub Standards Track Deferred 2012-07-27 XEP-0315 Data Forms XML Element Standards Track Deferred 2012-10-15 XEP-0316 MUC Eventing Protocol Standards Track Deferred 2013-01-03 XEP-0317 Hats Standards Track Deferred 2013-01-03 XEP-0318 Best Practices for Client Initiated Presence Probes Informational Deferred 2013-08-06 XEP-0319 Last User Interaction in Presence Standards Track Draft 2017-07-17 XEP-0320 Use of DTLS-SRTP in Jingle Sessions Standards Track Deferred 2015-10-15 XEP-0321 Remote Roster Management Standards Track Deferred 2013-04-16 XEP-0322 Efficient XML Interchange (EXI) Format Standards Track Deferred 2018-01-25 XEP-0323 Internet of Things - Sensor Data Standards Track Retracted 2017-05-20 XEP-0324 Internet of Things - Provisioning Standards Track Retracted 2017-05-20 XEP-0325 Internet of Things - Control Standards Track Retracted 2017-05-20 XEP-0326 Internet of Things - Concentrators Standards Track Retracted 2017-05-20 XEP-0327 Rayo Standards Track Deferred 2017-09-11 XEP-0328 JID Prep Standards Track Deferred 2013-05-28 XEP-0329 File Information Sharing Standards Track Deferred 2017-09-11 XEP-0330 Pubsub Subscription Standards Track Deferred 2013-06-11 XEP-0331 Data Forms - Color Field Types Standards Track Deferred 2015-11-09 XEP-0332 HTTP over XMPP transport Standards Track Deferred 2017-09-11 XEP-0333 Chat Markers Standards Track Deferred 2017-09-11 XEP-0334 Message Processing Hints Standards Track Deferred 2018-01-25 XEP-0335 JSON Containers Standards Track Deferred 2013-10-25 XEP-0336 Data Forms - Dynamic Forms Standards Track Deferred 2015-11-09 XEP-0337 Event Logging over XMPP Standards Track Deferred 2017-09-11 XEP-0338 Jingle Grouping Framework Standards Track Deferred 2017-09-11 XEP-0339 Source-Specific Media Attributes in Jingle Standards Track Deferred 2017-09-11 XEP-0340 COnferences with LIghtweight BRIdging (COLIBRI) Standards Track Deferred 2017-09-11 XEP-0341 Rayo CPA Standards Track Deferred 2017-09-11 XEP-0342 Rayo Fax Standards Track Deferred 2017-09-11 XEP-0343 Signaling WebRTC datachannels in Jingle Standards Track Deferred 2017-09-11 XEP-0344 Impact of TLS and DNSSEC on Dialback Standards Track Deferred 2017-09-11 XEP-0345 Form of Membership Applications Procedural Deferred 2017-09-11 XEP-0346 Form Discovery and Publishing Standards Track Deferred 2017-09-11 XEP-0347 Internet of Things - Discovery Standards Track Deferred 2017-09-11 XEP-0348 Signing Forms Standards Track Deferred 2017-09-11 XEP-0349 Rayo Clustering Standards Track Deferred 2017-09-11 XEP-0350 Data Forms Geolocation Element Standards Track Deferred 2017-09-11 XEP-0351 Recipient Server Side Notifications Filtering Standards Track Deferred 2017-09-11 XEP-0352 Client State Indication Standards Track Proposed 2017-02-18 XEP-0353 Jingle Message Initiation Standards Track Deferred 2017-09-11 XEP-0354 Customizable Message Routing Standards Track Deferred 2017-09-11 XEP-0355 Namespace Delegation Standards Track Deferred 2017-09-11 XEP-0356 Privileged Entity Standards Track Deferred 2017-09-11 XEP-0357 Push Notifications Standards Track Experimental 2017-08-24 XEP-0358 Publishing Available Jingle Sessions Standards Track Deferred 2017-09-11 XEP-0359 Unique and Stable Stanza IDs Standards Track Experimental 2017-08-23 XEP-0360 Nonzas (are not Stanzas) Standards Track Deferred 2017-09-11 XEP-0361 Zero Handshake Server to Server Protocol Informational Deferred 2017-09-11 XEP-0362 Raft over XMPP Standards Track Deferred 2017-09-11 XEP-0363 HTTP File Upload Standards Track Proposed 2018-05-30 XEP-0364 Current Off-the-Record Messaging Usage Informational Deferred 2017-01-28 XEP-0365 Server to Server communication over STANAG 5066 ARQ Standards Track Deferred 2018-07-21 XEP-0366 Entity Versioning Standards Track Deferred 2016-12-21 XEP-0367 Message Attaching Standards Track Deferred 2017-09-11 XEP-0368 SRV records for XMPP over TLS Standards Track Draft 2017-03-09 XEP-0369 Mediated Information eXchange (MIX) Standards Track Experimental 2018-06-06 XEP-0370 Jingle HTTP Transport Method Standards Track Deferred 2017-09-11 XEP-0371 Jingle ICE Transport Method Standards Track Deferred 2017-09-11 XEP-0372 References Standards Track Deferred 2017-09-11 XEP-0373 OpenPGP for XMPP Standards Track Experimental 2018-07-30 XEP-0374 OpenPGP for XMPP Instant Messaging Standards Track Deferred 2018-01-25 XEP-0375 XMPP Compliance Suites 2016 Standards Track Retracted 2016-07-20 XEP-0376 Pubsub Account Management Standards Track Deferred 2017-09-11 XEP-0377 Spam Reporting Standards Track Deferred 2017-09-11 XEP-0378 OTR Discovery Standards Track Deferred 2017-09-11 XEP-0379 Pre-Authenticated Roster Subscription Standards Track Experimental 2017-03-06 XEP-0380 Explicit Message Encryption Standards Track Deferred 2018-01-25 XEP-0381 Internet of Things Special Interest Group (IoT SIG) Procedural Proposed 2016-11-23 XEP-0382 Spoiler messages Standards Track Deferred 2018-01-25 XEP-0383 Burner JIDs Standards Track Deferred 2017-01-28 XEP-0384 OMEMO Encryption Standards Track Experimental 2018-05-21 XEP-0385 Stateless Inline Media Sharing (SIMS) Standards Track Experimental 2018-01-25 XEP-0386 Bind 2.0 Standards Track Deferred 2018-02-08 XEP-0387 XMPP Compliance Suites 2018 Standards Track Draft 2018-01-25 XEP-0388 Extensible SASL Profile Standards Track Experimental 2017-08-24 XEP-0389 Extensible In-Band Registration Standards Track Experimental 2017-03-16 XEP-0390 Entity Capabilities 2.0 Standards Track Experimental 2017-06-14 XEP-0391 Jingle Encrypted Transports Standards Track Experimental 2018-07-31 XEP-0392 Consistent Color Generation Standards Track Experimental 2018-07-28 XEP-0393 Message Styling Standards Track Experimental 2018-05-01 XEP-0394 Message Markup Standards Track Experimental 2017-11-22 XEP-0395 Atomically Compare-And-Publish PubSub Items Standards Track Experimental 2017-11-29 XEP-0396 Jingle Encrypted Transports - OMEMO Standards Track Experimental 2017-11-29 XEP-0397 Instant Stream Resumption Standards Track Experimental 2018-01-22 XEP-0398 User Avatar to vCard-Based Avatars Conversion Standards Track Experimental 2018-08-27 XEP-0399 Client Key Support Standards Track Experimental 2018-01-25 XEP-0400 Multi-Factor Authentication with TOTP Standards Track Experimental 2018-01-25 XEP-0401 Easy User Onboarding Standards Track Experimental 2018-02-11 XEP-0402 Bookmarks 2 (This Time it's Serious) Standards Track Experimental 2018-07-22 XEP-0403 Mediated Information eXchange (MIX): Presence Support. Standards Track Experimental 2018-06-06 XEP-0404 Mediated Information eXchange (MIX): JID Hidden Channels. Standards Track Experimental 2018-06-06 XEP-0405 Mediated Information eXchange (MIX): Participant Server Requirements Standards Track Experimental 2018-06-06 XEP-0406 Mediated Information eXchange (MIX): MIX Administration Standards Track Experimental 2018-06-06 XEP-0407 Mediated Information eXchange (MIX): Miscellaneous Capabilities Standards Track Experimental 2018-05-14 XEP-0408 Mediated Information eXchange (MIX): Co-existence with MUC Standards Track Experimental 2018-05-21 XEP-0409 IM Routing-NG Standards Track Experimental 2018-06-05 XEP-0410 MUC Self-Ping (Schrödinger's Chat) Standards Track Experimental 2018-08-31 ================================================ FILE: SiskinIM/ui/AboutController.swift ================================================ // // AboutController.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class AboutController: UIViewController { @IBOutlet var logoView: UIImageView!; @IBOutlet var nameLabel: UILabel! @IBOutlet var versionLabel: UILabel!; @IBOutlet var copyrightTextView: UITextView!; override func viewDidLoad() { logoView.layer.cornerRadius = 8; logoView.layer.masksToBounds = true; versionLabel.text = String.localizedStringWithFormat(NSLocalizedString("Version: %@", comment: "version of the app"), Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"); } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); } } ================================================ FILE: SiskinIM/ui/AvatarStatusView.swift ================================================ // // AvatarStatusView.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine class AvatarStatusView: UIView { @IBOutlet var avatarImageView: AvatarView! @IBOutlet var statusImageView: UIImageView! { didSet { statusImageView.backgroundColor = self.backgroundColor; } } private var cancellables: Set = []; var displayableId: DisplayableIdProtocol? { didSet { cancellables.removeAll(); if let namePublisher = displayableId?.displayNamePublisher, let avatarPublisher = displayableId?.avatarPublisher { namePublisher.combineLatest(avatarPublisher).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] name, image in self?.avatarImageView.set(name: name, avatar: image); }).store(in: &cancellables); } displayableId?.statusPublisher.map({ AvatarStatusView.getStatusImage($0) }).assign(to: \.image, on: statusImageView).store(in: &cancellables); } } override var backgroundColor: UIColor? { get { return super.backgroundColor; } set { super.backgroundColor = newValue; statusImageView?.backgroundColor = newValue; } } var status: Presence.Show? { didSet { statusImageView.image = AvatarStatusView.getStatusImage(status); } } /* // Only override drawRect: if you perform custom drawing. // An empty implementation adversely affects performance during animation. override func drawRect(rect: CGRect) { // Drawing code } */ override func awakeFromNib() { super.awakeFromNib(); //avatarImageView.image = UIImage(named: "first"); updateCornerRadius(); } func set(name: String?, avatar: UIImage?) { self.avatarImageView.set(name: name, avatar: avatar); } static func getStatusImage(_ status: Presence.Show?) -> UIImage? { // default color as for offline contact var image:UIImage? = UIImage(systemName: "circle.fill")?.withTintColor(UIColor.systemGray, renderingMode: .alwaysOriginal) if status != nil { switch status! { case .chat: image = UIImage(systemName: "asterisk.circle.fill")?.withTintColor(UIColor.green, renderingMode: .alwaysOriginal); case .online: image = UIImage(systemName: "circle.fill")?.withTintColor(UIColor.systemGreen, renderingMode: .alwaysOriginal) case .away: image = UIImage(systemName: "clock.fill")?.withTintColor(UIColor.systemOrange, renderingMode: .alwaysOriginal); case .xa: image = UIImage(systemName: "ellipsis.circle.fill")?.withTintColor(UIColor.systemOrange, renderingMode: .alwaysOriginal) case .dnd: image = UIImage(systemName: "moon.circle.fill")?.withTintColor(UIColor.systemRed, renderingMode: .alwaysOriginal) } } return image; } override func layoutSubviews() { super.layoutSubviews(); updateCornerRadius(); } func updateCornerRadius() { avatarImageView.layer.masksToBounds = true; avatarImageView.layer.cornerRadius = self.frame.height / 2; statusImageView.layer.opacity = 1.0; statusImageView.layer.masksToBounds = true; statusImageView.layer.cornerRadius = self.statusImageView.frame.height / 2; } } ================================================ FILE: SiskinIM/ui/AvatarView.swift ================================================ // // AvatarStatusView.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class AvatarView: UIImageView { private var name: String? { didSet { if let parts = name?.uppercased().components(separatedBy: CharacterSet.letters.inverted) { let first = parts.first?.first; let last = parts.count > 1 ? parts.last?.first : nil; self.initials = (last == nil || first == nil) ? (first == nil ? nil : "\(first!)") : "\(first!)\(last!)"; } else { self.initials = nil; } self.updateImage(); } } var avatar: UIImage? { didSet { updateImage(); } } override var frame: CGRect { didSet { self.layer.cornerRadius = min(frame.width, frame.height) / 2; } } override func layoutSubviews() { super.layoutSubviews(); self.layer.cornerRadius = min(frame.width, frame.height) / 2; } // override var image: UIImage? { // get { // return super.image; // } // set { // //if image != nil { // // self.image = prepareInitialsAvatar(); // //} // if newValue != nil { // super.image = newValue; // } else if let initials = self.initials { // super.image = prepareInitialsAvatar(for: initials); // } else { // super.image = nil; // } // } // } fileprivate(set) var initials: String?; private func updateImage() { if avatar != nil { // workaround to properly handle appearance // if self.avatar! == AvatarManager.instance.defaultGroupchatAvatar { self.image = self.avatar; // } else { // self.image = avatar?.square(max(self.frame.size.width, self.frame.size.height)); // } } else if let initials = self.initials { self.image = self.prepareInitialsAvatar(for: initials); } else { self.image = AvatarManager.instance.defaultAvatar; } } func set(name: String?, avatar: UIImage?) { self.name = name; self.avatar = avatar; self.setNeedsDisplay(); } func prepareInitialsAvatar(for text: String) -> UIImage? { let scale = UIScreen.main.scale; var size = self.bounds.size; if self.contentMode == .redraw || contentMode == .scaleAspectFill || contentMode == .scaleAspectFit || contentMode == .scaleToFill { size.width = (size.width * scale); size.height = (size.height * scale); } guard size.width > 0 && size.height > 0 else { return nil; } UIGraphicsBeginImageContextWithOptions(size, false, scale); guard let ctx = UIGraphicsGetCurrentContext() else { UIGraphicsEndImageContext(); return nil; } let path = CGPath(ellipseIn: self.bounds, transform: nil); ctx.addPath(path); let colors = [UIColor.systemGray.adjust(brightness: 0.52).cgColor, UIColor.systemGray.adjust(brightness: 0.48).cgColor]; let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors as CFArray, locations: [0.0, 1.0])!; ctx.drawLinearGradient(gradient, start: CGPoint.zero, end: CGPoint(x: 0, y: size.height), options: []); // ctx.setFillColor(UIColor.systemGray.cgColor); // ctx.fill(CGRect(x: 0, y: 0, width: size.width, height: size.height)); let textAttr: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.white.withAlphaComponent(0.9), .font: UIFont.systemFont(ofSize: size.width * 0.4, weight: .medium)]; let textSize = text.size(withAttributes: textAttr); text.draw(in: CGRect(x: size.width/2 - textSize.width/2, y: size.height/2 - textSize.height/2, width: textSize.width, height: textSize.height), withAttributes: textAttr); let image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return image; } } ================================================ FILE: SiskinIM/ui/CertificateErrorAlert.swift ================================================ // // CertificateErrorAlert.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class CertificateErrorAlert { public static func create(domain: String, certData: SslCertificateInfo, onAccept: (()->Void)?, onDeny: (()->Void)?) -> UIAlertController { return create(domain: domain, certName: certData.details.name, certHash: certData.details.fingerprintSha1, issuerName: certData.issuer?.name, issuerHash: certData.issuer?.fingerprintSha1, onAccept: onAccept, onDeny: onDeny); } public static func create(domain: String, certName: String, certHash: String, issuerName: String?, issuerHash: String?, onAccept: (()->Void)?, onDeny: (()->Void)?) -> UIAlertController { let issuer = issuerName != nil ? String.localizedStringWithFormat(NSLocalizedString("\nissued by\n%@\n with fingerprint\n%@", comment: "ssl certificate info - issue part"), issuerName!, issuerHash!) : ""; let alert = UIAlertController(title: NSLocalizedString("Certificate issue", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Server for domain %@ provided invalid certificate for %@\n with fingerprint\n%@%@.\nDo you trust this certificate?", comment: "ssl certificate alert dialog body"), domain, certName, certHash, issuer), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("No", comment: "button label"), style: .cancel, handler: CertificateErrorAlert.wrapActionHandler(onDeny))); alert.addAction(UIAlertAction(title: NSLocalizedString("Yes", comment: "button label"), style: .destructive, handler: CertificateErrorAlert.wrapActionHandler(onAccept))); return alert; } fileprivate static func wrapActionHandler(_ action: (()->Void)?) -> ((UIAlertAction)->Void)? { guard action != nil else { return nil; } return {(aa) in action!(); }; } } ================================================ FILE: SiskinIM/ui/ChartView.swift ================================================ // // ChartView.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class UsageChartView: UIStackView { private let barView = BarView(); private let labelsView = UIStackView(); var items: [Item] = [] { didSet { barView.items = items; for subview in labelsView.subviews { subview.removeFromSuperview(); } let formatter = ByteCountFormatter(); formatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB]; formatter.countStyle = .memory; for item in items { let row = UIStackView(); row.isOpaque = false; row.axis = .horizontal; row.spacing = 10; row.distribution = .fill; row.alignment = .center; let colorView = ColorDotView(); colorView.isOpaque = false; colorView.color = item.color; colorView.translatesAutoresizingMaskIntoConstraints = false; row.addArrangedSubview(colorView) NSLayoutConstraint.activate([ colorView.widthAnchor.constraint(equalTo: colorView.heightAnchor), colorView.heightAnchor.constraint(equalToConstant: 10), ]) let label = UILabel() label.font = UIFont.preferredFont(forTextStyle: .footnote); label.text = item.name; label.numberOfLines = 1; row.addArrangedSubview(label); let value = UILabel(); value.font = UIFont.preferredFont(forTextStyle: .footnote); value.text = formatter.string(fromByteCount: Int64(item.value)); value.textAlignment = .right; row.addArrangedSubview(value); labelsView.addArrangedSubview(row); } } } var maximumValue: Double = 100 { didSet { barView.totalValue = maximumValue; } } override init(frame: CGRect) { super.init(frame: frame); setup(); } required init(coder: NSCoder) { super.init(coder: coder); setup(); } func setup() { self.axis = .vertical; self.spacing = 10; barView.translatesAutoresizingMaskIntoConstraints = false; barView.setContentHuggingPriority(.defaultLow, for: .horizontal); barView.setContentHuggingPriority(.defaultLow, for: .vertical); barView.isOpaque = false; addArrangedSubview(barView); labelsView.axis = .vertical; labelsView.spacing = 4; labelsView.isOpaque = false; addArrangedSubview(labelsView); NSLayoutConstraint.activate([ self.barView.heightAnchor.constraint(equalToConstant: 15) ]); } struct Item { let color: UIColor; let value: Double; let name: String; } class BarView: UIView { var items: [Item] = []; var totalValue: Double = 100; override func draw(_ rect: CGRect) { UIBezierPath(roundedRect: rect, cornerRadius: 5).addClip(); UIColor.systemGray.setFill(); UIBezierPath(rect: rect).fill(); var x: CGFloat = 0; for item in items { let width = rect.width * CGFloat(item.value / totalValue); let path = UIBezierPath(rect: CGRect(x: x, y: 0, width: width, height: rect.height)); item.color.setFill(); path.fill(); x = x + width; } } } class ColorDotView: UIView { var color: UIColor?; override func awakeFromNib() { super.awakeFromNib(); isOpaque = false; setContentHuggingPriority(.defaultLow, for: .horizontal); setContentHuggingPriority(.defaultLow, for: .vertical); } override func draw(_ rect: CGRect) { let path = UIBezierPath(roundedRect: rect, cornerRadius: max(rect.height, rect.width)); path.addClip(); color?.setFill(); path.fill(); } } } ================================================ FILE: SiskinIM/ui/ChatBottomView.swift ================================================ // // ChatBottomView.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class ChatBottomView: UIView { } ================================================ FILE: SiskinIM/ui/CustomTabBarController.swift ================================================ // // CustomTabBarController.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class CustomTabBarController: UITabBarController { override func viewDidLoad() { self.tabBar.unselectedItemTintColor = self.tabBar.barStyle == .black ? nil : UIColor.lightGray.lighter(ratio: 0.1); } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection); if #available(iOS 13.0, *) { if previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) ?? false { self.tabBar.unselectedItemTintColor = self.tabBar.barStyle == .black ? nil : UIColor.lightGray; } } } } ================================================ FILE: SiskinIM/ui/DataFormController.swift ================================================ // // DataFormController.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class DataFormController: UITableViewController { var bob: [BobData] = []; var form: JabberDataElement?; var passwordSuggestNew: Bool?; var errors = [IndexPath](); override func viewDidLoad() { super.viewDidLoad(); tableView.register(TextSingleFieldCell.self, forCellReuseIdentifier: "FormViewCell-text-single"); tableView.register(TextPrivateFieldCell.self, forCellReuseIdentifier: "FormViewCell-text-private"); tableView.register(TextMultiFieldCell.self, forCellReuseIdentifier: "FormViewCell-text-multi"); tableView.register(JidSingleFieldCell.self, forCellReuseIdentifier: "FormViewCell-jid-single"); tableView.register(JidMultiFieldCell.self, forCellReuseIdentifier: "FormViewCell-jid-multi"); tableView.register(BooleanFieldCell.self, forCellReuseIdentifier: "FormViewCell-boolean"); tableView.register(FixedFieldCell.self, forCellReuseIdentifier: "FormViewCell-fixed"); tableView.register(ListSingleFieldCell.self, forCellReuseIdentifier: "FormViewCell-list-single"); tableView.register(ListMultiFieldCell.self, forCellReuseIdentifier: "FormViewCell-list-multi"); tableView.register(MediaFieldCell.self, forCellReuseIdentifier: "FormViewCell-media"); } override func viewWillAppear(_ animated: Bool) { tableView.reloadData(); super.viewWillAppear(animated); } override func numberOfSections(in tableView: UITableView) -> Int { guard form != nil else { return 0; } return 1 + form!.visibleFieldNames.count; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { guard form != nil && section != 0 else { return 0; } let fieldName = form!.visibleFieldNames[section - 1]; let field = form!.getField(named: fieldName)!; return 1 + field.media.count; } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { if section == 0 { let instructions: [String]? = form?.instructions as? [String]; return (instructions == nil || instructions!.isEmpty) ? NSLocalizedString("Please fill this form", comment: "instruction to fill out the form") : instructions!.joined(separator: "\n"); } else { let fieldName = form!.visibleFieldNames[section - 1]; return form?.getField(named: fieldName)?.label ?? fieldName; } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let fieldName = form!.visibleFieldNames[indexPath.section - 1]; let field = form!.getField(named: fieldName)!; let medias = field.media; if indexPath.row < medias.count { let media = medias[indexPath.row]; let cell = tableView.dequeueReusableCell(withIdentifier: "FormViewCell-media", for: indexPath) as! MediaFieldCell; if let uri = media.uris.first(where: { $0.type.starts(with: "image/") }) { if let bob = self.bob.first(where: { $0.matches(uri: uri.value) }) { cell.loadImage(bob: bob); } else { cell.loadImage(uri: uri.value); } } else { cell.loadError(); } return cell; } else { let cellId = "FormViewCell-" + ( field.type ?? "fixed" ); let cell = tableView.dequeueReusableCell(withIdentifier: cellId, for: indexPath); (cell as? FieldCell)?.field = field; if field.type == "list-single" || field.type == "list-multi" || field.type == "text-multi" || field.type == "jid-multi" { cell.accessoryType = .disclosureIndicator; } if let passwordSuggestNew = self.passwordSuggestNew, let c = cell as? TextPrivateFieldCell { c.passwordSuggestNew = passwordSuggestNew; } return cell; } } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { return nil; } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false); guard indexPath.section > 0, let fieldName = form?.visibleFieldNames[indexPath.section - 1] else { return; } let field = form!.getField(named: fieldName)!; if field.type == "list-single" || field.type == "list-multi" { let listController = ListSelectorController(style: .grouped); listController.field = field as? ListField; self.navigationController?.pushViewController(listController, animated: true); } else if field.type == "text-multi" { let textController = TextController(); textController.field = field as? TextMultiField; self.navigationController?.pushViewController(textController, animated: true); } else if field.type == "jid-multi" { let jidsController = JidsController(); jidsController.field = field as? JidMultiField; self.navigationController?.pushViewController(jidsController, animated: true); } } override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { if errors.firstIndex(where: { (idx)->Bool in return idx.row == indexPath.row && idx.section == indexPath.section }) != nil { var backgroundColor = UIColor.white; if #available(iOS 13.0, *) { backgroundColor = UIColor.systemBackground; } UIView.animate(withDuration: 0.5, animations: { //cell.backgroundColor = UIColor(red: 1.0, green: 0.5, blue: 0.5, alpha: 1); cell.backgroundColor = UIColor(hue: 0, saturation: 0.7, brightness: 0.8, alpha: 1) }, completion: {(b) in UIView.animate(withDuration: 0.5) { cell.backgroundColor = backgroundColor; } }); } } func validateForm() -> Bool { guard form != nil else { return false; } var errors = [IndexPath](); for (index, fieldName) in form!.visibleFieldNames.enumerated() { if let field = form!.getField(named: fieldName)! as? ValidatableField { if !field.valid { errors.append(IndexPath(row: 0, section: index + 1)); } } } self.errors = errors; tableView.reloadRows(at: errors, with: .none); return errors.isEmpty; } class TextSingleFieldCell: AbstractTextSingleFieldCell { override var field: Field? { didSet { guard let f: TextSingleField = field as? TextSingleField else { value = nil; return; } value = f.value; } } override func textDidChanged(textField: UITextField) { (field as? TextSingleField)?.value = textField.text; } } class TextPrivateFieldCell: AbstractTextSingleFieldCell { var passwordSuggestNew: Bool? { didSet { guard let v = passwordSuggestNew else { return; } uiTextField?.textContentType = v ? .newPassword : .password; } } override var field: Field? { didSet { uiTextField.isSecureTextEntry = true; guard let f: TextPrivateField = field as? TextPrivateField else { value = nil; return; } value = f.value; } } override func textDidChanged(textField: UITextField) { (field as? TextPrivateField)?.value = textField.text; } } class AbstractTextSingleFieldCell: AbstractFieldCell { var uiTextField: UITextField! { return fieldView as? UITextField; } override var fieldView: UIView? { didSet { uiTextField.addTarget(self, action: #selector(textDidChanged(textField:)), for: .editingChanged); } } var value: String? { get { return uiTextField.text; } set { uiTextField.text = newValue; } } override func createFieldView() -> UIView? { let field = UITextField(); field.autocorrectionType = .no; field.autocapitalizationType = .none; return field; } @objc fileprivate func textDidChanged(textField: UITextField) { } } class TextMultiFieldCell: AbstractFieldCell { var uiTextField: UILabel! { return fieldView as? UILabel; } var value: String? { get { return uiTextField.text; } set { uiTextField.text = newValue; } } override var field: Field? { didSet { guard let f: TextMultiField = field as? TextMultiField else { value = nil; return; } value = f.value.joined(separator: " "); } } override func createFieldView() -> UIView? { let label = UILabel(); label.lineBreakMode = .byTruncatingTail; label.numberOfLines = 1; return label; } } class JidSingleFieldCell: AbstractFieldCell { var uiTextField: UITextField! { return fieldView as? UITextField; } override var fieldView: UIView? { didSet { uiTextField.addTarget(self, action: #selector(textDidChanged(textField:)), for: .valueChanged); } } var value: JID? { get { return JID(uiTextField.text); } set { uiTextField.text = newValue?.stringValue; } } override var field: Field? { didSet { guard let f: JidSingleField = field as? JidSingleField else { value = nil; return; } value = f.value; } } override func createFieldView() -> UIView? { let field = UITextField(); field.autocorrectionType = .no; field.autocapitalizationType = .none; field.keyboardType = .emailAddress; return field; } @objc func textDidChanged(textField: UITextField) { (field as? JidSingleField)?.value = JID(textField.text); } } class JidMultiFieldCell: AbstractFieldCell { var uiTextField: UILabel! { return fieldView as? UILabel; } var value: [JID] { get { return uiTextField.text?.components(separatedBy: "\n").map({(str)->JID? in JID(str) }).filter({(jid)->Bool in jid != nil}).map({(jid)->JID in jid!}) ?? [JID](); } set { uiTextField.text = newValue.map({(jid)->String in jid.stringValue}).joined(separator: " "); } } override var field: Field? { didSet { guard let f: JidMultiField = field as? JidMultiField else { value = []; return; } value = f.value; } } override func createFieldView() -> UIView? { return UILabel(); } } class BooleanFieldCell: UITableViewCell, FieldCell { var label: String? { get { return self.textLabel?.text; } set { self.textLabel?.text = newValue; } } var uiSwitch: UISwitch! { return fieldView as? UISwitch; } var field: Field? { didSet { label = field?.label ?? field?.name.capitalized; value = (field as? BooleanField)?.value ?? false; } } var fieldView: UIView? { didSet { uiSwitch.addTarget(self, action: #selector(switchValueChanged(switch:)), for: .valueChanged); } } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: UITableViewCell.CellStyle.value1, reuseIdentifier: reuseIdentifier); initialize(field: createFieldView()); } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder); initialize(field: createFieldView()); initialize(field: fieldView); } func initialize(field: UIView?) { self.fieldView = field; accessoryView = field; } var value: Bool { get { return uiSwitch.isOn; } set { uiSwitch.isOn = newValue; } } func createFieldView() -> UIView? { return UISwitch(); } @objc func switchValueChanged(switch uiswitch: UISwitch) { (field as? BooleanField)?.value = uiswitch.isOn; } } class FixedFieldCell: AbstractFieldCell { var value: String? { get { return self.textLabel?.text; } set { self.textLabel?.text = newValue; self.textLabel?.sizeToFit(); } } override var field: Field? { didSet { //label = field?.label ?? field?.name.capitalized; value = (field as? FixedField)?.value; } } override func createFieldView() -> UIView? { textLabel?.lineBreakMode = .byWordWrapping; textLabel?.numberOfLines = 0; return nil; } } class ListSingleFieldCell: AbstractFieldCell { var value: String? { get { return (fieldView as? UILabel)?.text; } set { (fieldView as? UILabel)?.text = newValue; } } override var field: Field? { didSet { if let f: ListSingleField = field as? ListSingleField { let value = f.value; let selected = f.options.first(where: { (option) -> Bool in option.value == value; }); self.value = selected?.label ?? selected?.value; } } } override func createFieldView() -> UIView? { let label = UILabel(); // label.textAlignment = .right; return label; } } class ListMultiFieldCell: AbstractFieldCell { var value: String? { get { return (fieldView as? UILabel)?.text; } set { (fieldView as? UILabel)?.text = newValue; } } override var field: Field? { didSet { if let f: ListMultiField = field as? ListMultiField { let value = f.value; let selected = f.options.filter({ (option) -> Bool in return value.firstIndex(of: option.value) != nil; }); self.value = selected.map({ (option) -> String in option.label ?? option.value }).joined(separator: ", "); } } } override func createFieldView() -> UIView? { let label = UILabel(); return label; } } class AbstractFieldCell: UITableViewCell, FieldCell { var field: Field?; var fieldView: UIView?; override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: UITableViewCell.CellStyle.default, reuseIdentifier: reuseIdentifier); initialize(field: createFieldView()); } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder); initialize(field: createFieldView()); initialize(field: fieldView); } func initialize(field: UIView?) { self.preservesSuperviewLayoutMargins = true; self.insetsLayoutMarginsFromSafeArea = true; self.fieldView = field; guard field != nil else { return; } field!.insetsLayoutMarginsFromSafeArea = true; field!.translatesAutoresizingMaskIntoConstraints = false; field!.preservesSuperviewLayoutMargins = true; contentView.addSubview(field!); addConstraints([ field!.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), field!.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), field!.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), field!.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8) ]); } func createFieldView() -> UIView? { return nil; } } class MediaFieldCell: UITableViewCell { private let mediaView = UIImageView(); override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: UITableViewCell.CellStyle.default, reuseIdentifier: reuseIdentifier); setup(); } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder); setup(); } func setup() { mediaView.translatesAutoresizingMaskIntoConstraints = false; mediaView.contentMode = .scaleAspectFit; self.contentView.addSubview(mediaView); NSLayoutConstraint.activate([ contentView.leadingAnchor.constraint(equalTo: mediaView.leadingAnchor), contentView.trailingAnchor.constraint(equalTo: mediaView.trailingAnchor), contentView.topAnchor.constraint(equalTo: mediaView.topAnchor), contentView.bottomAnchor.constraint(equalTo: mediaView.bottomAnchor) ]); } func loadImage(bob: BobData) { if let data = bob.data, let image = UIImage(data: data) { self.mediaView.image = image; } else { self.mediaView.image = UIImage(systemName: "multiply.circle.fill")?.withTintColor(UIColor.systemRed, renderingMode: .alwaysOriginal) } } func loadImage(uri: String) { if uri.starts(with: "cid:") { self.loadError(); } else { if let url = URL(string: uri) { DispatchQueue.global().async { [weak self] in if let data = try? Data(contentsOf: url), let image = UIImage(data: data) { DispatchQueue.main.async { self?.mediaView.image = image; } } else { DispatchQueue.main.async { self?.loadError(); } } } } else { self.loadError(); } } } func loadError() { self.mediaView.image = UIImage(systemName: "multiply.circle.fill")?.withTintColor(UIColor.systemRed, renderingMode: .alwaysOriginal); } } class ListSelectorController: UITableViewController { var field: ListField! { didSet { options = field.options; } } var options: [ListFieldOption] = []; override func viewDidLoad() { tableView.allowsSelection = true; tableView.allowsMultipleSelection = (field as? ListMultiField) != nil; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return field.options.count; } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { return (field as? Field)?.label ?? (field as? Field)?.name.capitalized; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell(style: .value1, reuseIdentifier: nil); let option = options[indexPath.row]; cell.textLabel?.text = option.label ?? option.value; return cell; } override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { let option = options[indexPath.row]; if let multiList: ListMultiField = field as? ListMultiField { let values = multiList.value; cell.accessoryType = values.firstIndex(of: option.value) != nil ? .checkmark : .none; } else if let singleList: ListSingleField = field as? ListSingleField { cell.accessoryType = singleList.value == option.value ? .checkmark : .none; } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true); let value = options[indexPath.row].value; if let multiList: ListMultiField = field as? ListMultiField { var values = multiList.value; if let idx = values.firstIndex(of: value) { values.remove(at: idx); } else { values.append(value); } multiList.value = values; } else if let singleList: ListSingleField = field as? ListSingleField { if singleList.value == value { singleList.value = nil; } else { singleList.value = value; } } tableView.reloadData(); } } class JidsController: UIViewController, UITextViewDelegate { var textView = UITextView(); var field: JidMultiField! { didSet { textView.text = field.value.map({(jid)->String in jid.stringValue}).joined(separator: "\n"); } } override func viewDidLoad() { textView.delegate = self; textView.allowsEditingTextAttributes = false; textView.autocorrectionType = .no; textView.autocapitalizationType = .none; super.viewDidLoad(); textView.translatesAutoresizingMaskIntoConstraints = false; view.addSubview(textView); view.addConstraints([ NSLayoutConstraint(item: textView, attribute: .leading, relatedBy: .equal, toItem: self.view, attribute: .leading, multiplier: 1, constant: 8), NSLayoutConstraint(item: textView, attribute: .top, relatedBy: .equal, toItem: self.view, attribute: .top, multiplier: 1, constant: 8), NSLayoutConstraint(item: textView, attribute: .bottom, relatedBy: .equal, toItem: self.view, attribute: .bottom, multiplier: 1, constant: -8), NSLayoutConstraint(item: textView, attribute: .trailing, relatedBy: .equal, toItem: self.view, attribute: .trailing, multiplier: 1, constant: 8) ]); } func textViewDidChange(_ textView: UITextView) { let values = textView.text.components(separatedBy: "\n"); let results = values.map({(str)->JID? in JID(str) }).filter({(jid)->Bool in jid != nil}).map({(jid)->JID in jid!}); field.value = results; } } class TextController: UIViewController, UITextViewDelegate { var textView = UITextView(); var field: TextMultiField! { didSet { textView.text = field.rawValue.joined(separator: "\n"); } } override func viewDidLoad() { textView.delegate = self; textView.allowsEditingTextAttributes = false; textView.autocorrectionType = .no; textView.autocapitalizationType = .none; super.viewDidLoad(); textView.translatesAutoresizingMaskIntoConstraints = false; view.addSubview(textView); view.addConstraints([ NSLayoutConstraint(item: textView, attribute: .leading, relatedBy: .equal, toItem: self.view, attribute: .leading, multiplier: 1, constant: 8), NSLayoutConstraint(item: textView, attribute: .top, relatedBy: .equal, toItem: self.view, attribute: .top, multiplier: 1, constant: 8), NSLayoutConstraint(item: textView, attribute: .bottom, relatedBy: .equal, toItem: self.view, attribute: .bottom, multiplier: 1, constant: -8), NSLayoutConstraint(item: textView, attribute: .trailing, relatedBy: .equal, toItem: self.view, attribute: .trailing, multiplier: 1, constant: 8) ]); } func textViewDidChange(_ textView: UITextView) { field.value = textView.text.components(separatedBy: "\n"); } } } protocol FieldCell: AnyObject { var field: Field? { get set } } ================================================ FILE: SiskinIM/ui/EmptyViewController.swift ================================================ // // EmptyViewController.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class EmptyViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); } } ================================================ FILE: SiskinIM/ui/EnumTableViewCell.swift ================================================ // // EnumTableViewCell.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Combine class EnumTableViewCell: UITableViewCell { private var cancellables: Set = []; func reset() { cancellables.removeAll(); } func assign(from publisher: AnyPublisher) { if let label = self.detailTextLabel { publisher.assign(to: \.text, on: label).store(in: &cancellables); } } func assign(from publisher: AnyPublisher) { if let label = self.detailTextLabel { publisher.map({ $0.description }).assign(to: \.text, on: label).store(in: &cancellables); } } func assign(from publisher: AnyPublisher) { if let label = self.detailTextLabel { publisher.map({ $0.description }).assign(to: \.text, on: label).store(in: &cancellables); } } func assign(from publisher: AnyPublisher) { if let label = self.detailTextLabel { publisher.map({ $0?.description }).assign(to: \.text, on: label).store(in: &cancellables); } } func bind(_ fn: (EnumTableViewCell)->Void) { reset(); fn(self); } } ================================================ FILE: SiskinIM/ui/GetInTouchViewController.swift ================================================ // // GetInTouchViewController.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class GetInTouchViewController: UITableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true); switch indexPath.section { case 0: switch indexPath.row { case 1: UIApplication.shared.open(URL(string: "https://github.com/tigase/siskin-im")!) case 2: (UIApplication.shared.delegate as! AppDelegate).open(xmppUri: AppDelegate.XmppUri(jid: JID("tigase@muc.tigase.org"), action: .join, dict: nil), action: .join); default: UIApplication.shared.open(URL(string: "https://siskin.im")!); } default: switch indexPath.row { case 1: UIApplication.shared.open(URL(string: "https://twitter.com/tigase")!); case 2: UIApplication.shared.open(URL(string: "https://fosstodon.org/@tigase")!); default: UIApplication.shared.open(URL(string: "https://tigase.net")!); } } } } ================================================ FILE: SiskinIM/ui/GlobalSplitViewController.swift ================================================ // // GlobalSplitViewController.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class GlobalSplitViewController: UISplitViewController, UISplitViewControllerDelegate { // override var preferredStatusBarStyle: UIStatusBarStyle { // return Appearance.current.isDark ? .lightContent : .default; // } override func viewDidLoad() { super.viewDidLoad(); self.delegate = self; } func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool{ return true } func splitViewController(_ splitViewController: UISplitViewController, showDetail detailvc: UIViewController, sender: Any?) -> Bool { let mastervc = splitViewController.viewControllers[0] as! UITabBarController; if splitViewController.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClass.compact { // mastervc.selectedViewController?.showViewController(detailvc, sender: sender); if let detail = detailvc as? UINavigationController { (mastervc.selectedViewController as? UINavigationController)?.pushViewController(detail.viewControllers[0], animated: true); } else { (mastervc.selectedViewController as? UINavigationController)?.pushViewController(detailvc, animated: true); } } else { splitViewController.viewControllers = [mastervc, detailvc]; } return true; } func splitViewController(_ splitViewController: UISplitViewController, separateSecondaryFrom primaryViewController: UIViewController) -> UIViewController? { let mastervc = splitViewController.viewControllers[0] as! UITabBarController; if let uinav = mastervc.selectedViewController as? UINavigationController { if uinav.viewControllers.count > 1 { return uinav.popViewController(animated: false); } } return nil; } } ================================================ FILE: SiskinIM/ui/MainTabBarController.swift ================================================ // // MainTabBarController.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine class MainTabBarController: CustomTabBarController, UITabBarControllerDelegate { public static let RECENTS_TAB = 0; public static let ROSTER_TAB = 1; public static let BOOKMARKS_TAB = 2; private var cancellables: Set = []; override func viewDidLoad() { super.viewDidLoad(); self.delegate = self; } } ================================================ FILE: SiskinIM/ui/Markdown.swift ================================================ // // Markdown.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import TigaseLogging extension unichar: ExpressibleByUnicodeScalarLiteral { public typealias UnicodeScalarLiteralType = UnicodeScalar public init(unicodeScalarLiteral value: UnicodeScalar) { self.init(value.value); } } class Markdown { static let quoteParagraphStyle: NSParagraphStyle = { var paragraphStyle = NSMutableParagraphStyle(); paragraphStyle.headIndent = 16; paragraphStyle.firstLineHeadIndent = 4; paragraphStyle.alignment = .natural; return paragraphStyle; }(); static let codeParagraphStyle: NSParagraphStyle = { var paragraphStyle = NSMutableParagraphStyle(); paragraphStyle.headIndent = 10; paragraphStyle.tailIndent = -10; paragraphStyle.firstLineHeadIndent = 10; paragraphStyle.alignment = .natural; return paragraphStyle; }(); static func font(withTextStyle textStyle: UIFont.TextStyle, andTraits traits: UIFontDescriptor.SymbolicTraits) -> UIFont { let preferredFont = UIFont.preferredFont(forTextStyle: textStyle); let fontDescription = preferredFont.fontDescriptor.withSymbolicTraits(traits)!; let newFont = UIFontMetrics(forTextStyle: textStyle).scaledFont(for: UIFont(descriptor: fontDescription, size: preferredFont.pointSize - 1)); return newFont; } static func code(withTextStyle textStyle: UIFont.TextStyle) -> UIFont { let preferredFont = UIFont.preferredFont(forTextStyle: textStyle); return UIFontMetrics(forTextStyle: textStyle).scaledFont(for: UIFont(descriptor: preferredFont.fontDescriptor.withDesign(.monospaced)!, size: preferredFont.fontDescriptor.pointSize - 1)); } static let NEW_LINE: unichar = "\n"; static let GT_SIGN: unichar = ">"; static let SPACE: unichar = " "; static let ASTERISK: unichar = "*"; static let UNDERSCORE: unichar = "_"; static let GRAVE_ACCENT: unichar = "`"; static let CR_SIGN: unichar = "\r"; private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Markdown"); static func applyStyling(attributedString msg: NSMutableAttributedString, defTextStyle: UIFont.TextStyle, showEmoticons: Bool) { let stylingColor = UIColor.init(white: 0.5, alpha: 1.0); var message = msg.string as NSString; var boldStart: Int? = nil; var italicStart: Int? = nil; var underlineStart: Int? = nil; var quoteStart: Int? = nil; var quoteLevel = 0; var idx = 0; var canStart = true; var wordIdx: Int? = showEmoticons ? 0 : nil; msg.removeAttribute(.underlineStyle, range: NSRange(location: 0, length: msg.length)); msg.removeAttribute(.paragraphStyle, range: NSRange(location: 0, length: msg.length)); msg.addAttribute(.font, value: font(withTextStyle: defTextStyle, andTraits: []), range: NSRange(location: 0, length: msg.length)); while idx < message.length { let c = message.character(at: idx); switch c { case GT_SIGN: if quoteStart == nil && (idx == 0 || message.character(at: idx-1) == NEW_LINE) { let start = idx; while idx < message.length, message.character(at: idx) == GT_SIGN { idx = idx + 1; } if idx < message.length && message.character(at: idx) == SPACE { quoteStart = start; quoteLevel = idx - start; msg.addAttribute(.foregroundColor, value: stylingColor, range: NSRange(location: start, length: idx - start)); } else { idx = idx - 1; } } case ASTERISK: let nidx = idx + 1; if nidx < message.length, message.character(at: nidx) == ASTERISK { if boldStart == nil { if canStart { boldStart = idx; } } else { msg.addAttribute(.foregroundColor, value: stylingColor, range: NSRange(location: boldStart!, length: (nidx+1) - idx)); msg.addAttribute(.foregroundColor, value: stylingColor, range: NSRange(location: idx, length: (nidx+1) - idx)); msg.enumerateAttribute(.font, in: NSRange(location: boldStart!, length: (nidx+1) - boldStart!), options: .init()) { (attr, range: NSRange, stop) -> Void in let boldFont = Markdown.font(withTextStyle: defTextStyle, andTraits: .traitBold); msg.addAttribute(.font, value: boldFont, range: range); } boldStart = nil; } canStart = true; idx = nidx; } else { if italicStart == nil { if canStart { italicStart = idx; } } else { msg.addAttribute(.foregroundColor, value: stylingColor, range: NSRange(location: italicStart!, length: 1)); msg.addAttribute(.foregroundColor, value: stylingColor, range: NSRange(location: idx, length: 1)); msg.enumerateAttribute(.font, in: NSRange(location: italicStart!, length: (idx+1) - italicStart!), options: .init()) { (attr, range: NSRange, stop) -> Void in let italicFont = Markdown.font(withTextStyle: defTextStyle, andTraits: .traitItalic) msg.addAttribute(.font, value: italicFont, range: range); } italicStart = nil; } canStart = true; } case UNDERSCORE: if underlineStart == nil { if canStart { underlineStart = idx; } } else { msg.addAttribute(.foregroundColor, value: stylingColor, range: NSRange(location: underlineStart!, length: 1)); msg.addAttribute(.foregroundColor, value: stylingColor, range: NSRange(location: idx, length: 1)); msg.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: underlineStart!, length: idx - underlineStart!)); underlineStart = nil; } canStart = true; case GRAVE_ACCENT: // if codeStart == nil { if canStart { let codeStart = idx; let isBlock = 0 == idx || (message.character(at: idx-1) == NEW_LINE) || (idx > 3 && message.length > (idx + 1) && message.character(at: idx + 1) == SPACE && message.character(at: idx-2) == GT_SIGN && (0 == idx - 3 || message.character(at: idx - 3) == NEW_LINE)); wordIdx = nil; while idx < message.length, message.character(at: idx) == "`" { idx = idx + 1; } let codeCount = idx - codeStart; var count = 0; while idx < message.length { if message.character(at: idx) == GRAVE_ACCENT { count = count + 1; if count == codeCount { let tmp = idx + 1; if tmp == message.length || [" ", "\n"].contains(message.character(at: tmp)) { break; } } } else { count = 0; } idx = idx + 1; } if codeCount != count { idx = codeStart + codeCount; } else { msg.addAttribute(.foregroundColor, value: stylingColor, range: NSRange(location: codeStart, length: codeCount)); msg.addAttribute(.foregroundColor, value: stylingColor, range: NSRange(location: (idx+1)-codeCount, length: codeCount)); let codeFont = Markdown.code(withTextStyle: defTextStyle); msg.addAttribute(.font, value: codeFont, range: NSRange(location: codeStart, length: idx - codeStart)); if isBlock { msg.addAttribute(.paragraphStyle, value: codeParagraphStyle, range: NSRange(location: codeStart, length: idx - codeStart)); } if idx - codeStart > 1 { let clearRange = NSRange(location: codeStart + codeCount, length: idx - (codeStart + (2*codeCount))); //msg.removeAttribute(.foregroundColor, range: clearRange); msg.removeAttribute(.underlineStyle, range: clearRange); //msg.addAttribute(.foregroundColor, value: textColor ?? NSColor.textColor, range: clearRange); } if idx == message.length { wordIdx = message.length; } else { wordIdx = idx + 1; } } } // } else { // } canStart = true; case CR_SIGN, NEW_LINE, SPACE: if showEmoticons { if wordIdx != nil && wordIdx! != idx { // something is wrong, it looks like IDX points to replaced value! let range = NSRange(location: wordIdx!, length: idx - wordIdx!); if let emoji = String.emojis[message.substring(with: range)] { let len = message.length; logger.debug("replacing: \(range), for: \(emoji), in: \(msg), range: \(NSRange(location: 0, length: msg.length))"); msg.replaceCharacters(in: range, with: emoji); message = msg.string as NSString; let diff = message.length - len; idx = idx + diff; } } if idx < message.length { wordIdx = idx + 1; } else { wordIdx = message.length; } } if NEW_LINE == c { boldStart = nil; underlineStart = nil; italicStart = nil if (quoteStart != nil) { logger.debug("quote level: \(quoteLevel)"); if idx < message.length { let range = NSRange(location: quoteStart!, length: idx - quoteStart!); logger.debug("message possibly causing a crash: \(message), range: \(range), length: \(message.length)"); msg.addAttribute(.paragraphStyle, value: Markdown.quoteParagraphStyle, range: range); } quoteStart = nil; } } canStart = true; default: canStart = false; break; } if idx < message.length { idx = idx + 1; } } if (quoteStart != nil) { msg.addAttribute(.paragraphStyle, value: Markdown.quoteParagraphStyle, range: NSRange(location: quoteStart!, length: idx - quoteStart!)); quoteStart = nil; } if showEmoticons && wordIdx != nil && wordIdx! != idx { let range = NSRange(location: wordIdx!, length: idx - wordIdx!); if let emoji = String.emojis[message.substring(with: range)] { msg.replaceCharacters(in: range, with: emoji); message = msg.string as NSString; } } msg.fixAttributes(in: NSRange(location: 0, length: msg.length)); } } extension String { static let emojisList = [ "😳": ["O.o"], "☺️": [":-$", ":$"], "😄": [":-D", ":D", ":-d", ":d", ":->", ":>"], "😉": [";-)", ";)"], "😊": [":-)", ":)"], "😡": [":-@", ":@"], "😕": [":-S", ":S", ":-s", ":s", ":-/", ":/"], "😭": [";-(", ";("], "😮": [":-O", ":O", ":-o", ":o"], "😎": ["B-)", "B)"], "😐": [":-|", ":|"], "😛": [":-P", ":P", ":-p", ":p"], "😟": [":-(", ":("] ]; static var emojis: [String:String] = Dictionary(uniqueKeysWithValues: String.emojisList.flatMap({ (arg0) -> [(String,String)] in let (k, list) = arg0 return list.map { v in return (v, k)}; })); func emojify() -> String { var result = self; let words = components(separatedBy: " ").filter({ s in !s.isEmpty}); for word in words { if let emoji = String.emojis[word] { result = result.replacingOccurrences(of: word, with: emoji); } } return result; } } ================================================ FILE: SiskinIM/ui/MessageTextView.swift ================================================ // // MessageTextView.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit //@IBDesignable public class MessageTextView: UIView { // @IBInspectable public var fontSize: CGFloat = 14.0; private(set) var textView: UITextView!; var attributedText: NSAttributedString? { get { return textView.attributedText; } set { textView.attributedText = newValue; } } var text: String? { get { return textView.text; } set { textView.text = newValue; } } // @IBInspectable var textColor: UIColor = { // if #available(iOS 13.0, *) { // return UIColor.label; // } else { // return UIColor.black; // } // }(); public override func awakeFromNib() { super.awakeFromNib(); self.backgroundColor = UIColor.blue; let layoutManager = CustomLayoutManager(); let textContainer = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude)); textContainer.widthTracksTextView = true; let textStorage = NSTextStorage(); textStorage.addLayoutManager(layoutManager); layoutManager.addTextContainer(textContainer); //textContainer.replaceLayoutManager(layoutManager); self.textView = UITextView(frame: .zero, textContainer: textContainer); textView.translatesAutoresizingMaskIntoConstraints = false; textView.isScrollEnabled = false; textContainer.lineFragmentPadding = 1; self.textView.textContainerInset = .zero; // textContainer.widthTracksTextView = false; textContainer.heightTracksTextView = false; textView.isEditable = false; textView.isSelectable = true; textView.isUserInteractionEnabled = true; textView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) textView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) textView.font = UIFont.preferredFont(forTextStyle: .subheadline); textView.textColor = UIColor(named: "chatMessageText"); textView.usesStandardTextScaling = false; self.addSubview(textView); NSLayoutConstraint.activate([ self.leadingAnchor.constraint(equalTo: textView.leadingAnchor), self.trailingAnchor.constraint(equalTo: textView.trailingAnchor), self.topAnchor.constraint(equalTo: textView.topAnchor), self.bottomAnchor.constraint(equalTo: textView.bottomAnchor) ]) } class CustomLayoutManager: NSLayoutManager { override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) { super.drawBackground(forGlyphRange: glyphsToShow, at: origin); // let rect = self.boundingRect(forGlyphRange: glyphsToShow, in: self.textContainers.first!); let charRange = self.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil); textStorage!.enumerateAttribute(.paragraphStyle, in: charRange, options: [], using: { (value, range, pth) in guard let paragraph = value as? NSParagraphStyle else { return; } if paragraph.tailIndent != 0 { let glyphRange = self.glyphRange(forCharacterRange: range, actualCharacterRange: nil); let rect = self.boundingRect(forGlyphRange: glyphRange, in: self.textContainers.first!) UIColor.label.withAlphaComponent(0.5).setFill(); let path = UIBezierPath(rect: CGRect(origin: CGPoint(x: origin.x + rect.origin.x, y: origin.y + rect.origin.y), size: CGSize(width: 2, height: rect.height))); path.fill(); } else if paragraph.headIndent != 0 { let glyphRange = self.glyphRange(forCharacterRange: range, actualCharacterRange: nil); let rect = self.boundingRect(forGlyphRange: glyphRange, in: self.textContainers.first!) UIColor.label.withAlphaComponent(0.2).setFill(); let path = UIBezierPath(rect: CGRect(x: (rect.origin.x > paragraph.firstLineHeadIndent) ? 1 + origin.x : rect.origin.x + origin.x, y: rect.origin.y + origin.y, width: 2, height: rect.height)); path.fill(); } }) } } } ================================================ FILE: SiskinIM/ui/NavigationControllerWrappingSegue.swift ================================================ // // NavigationControllerWrappingSegue.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class NavigationControllerWrappingSegue: UIStoryboardSegue { override init(identifier: String?, source: UIViewController, destination: UIViewController) { let navController = UINavigationController(rootViewController: destination); super.init(identifier: identifier, source: source, destination: navController) } } ================================================ FILE: SiskinIM/ui/RoundButton.swift ================================================ // // RoundButton.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class RoundButton: UIButton { override func draw(_ rect: CGRect) { let offset = max(rect.width, rect.height) / 2; let tmp = CGRect(x: offset, y: offset, width: rect.width - (2 * offset), height: rect.height - (2 * offset)); super.draw(tmp); } override func layoutSubviews() { super.layoutSubviews(); layer.masksToBounds = true; layer.cornerRadius = self.frame.height / 2; } } class RoundedButton: UIButton { override func draw(_ rect: CGRect) { let offset = rect.height / 2; let tmp = CGRect(x: offset, y: offset, width: rect.width - (2 * offset), height: rect.height - (2 * offset)); super.draw(tmp); } override func layoutSubviews() { super.layoutSubviews(); layer.masksToBounds = true; layer.cornerRadius = min(self.frame.height, self.frame.width) / 2; } } class BadgeButton: RoundedButton { var widthConstratint: NSLayoutConstraint?; var title: String? { didSet { self.setTitle(title, for: .normal); self.isHidden = title?.isEmpty ?? true; if (isHidden) { NSLayoutConstraint.activate([widthConstratint!]) } else { NSLayoutConstraint.deactivate([widthConstratint!]); } self.layoutSubviews(); self.setNeedsDisplay(); } } override init(frame: CGRect) { super.init(frame: frame) setup(); } required init?(coder: NSCoder) { super.init(coder: coder); setup(); } private func setup() { widthConstratint = self.widthAnchor.constraint(equalToConstant: 0); NSLayoutConstraint.activate([widthConstratint!]); isHidden = true; } } ================================================ FILE: SiskinIM/ui/StepperTableViewCell.swift ================================================ // // StepperTableViewCell.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Combine class StepperTableViewCell: UITableViewCell { @IBOutlet var labelView: UILabel! @IBOutlet var stepperView: UIStepper! var valueChangedListener: ((UIStepper) -> Void)?; var updateLabel: ((Double)->String?)?; private var cancellables: Set = []; override func awakeFromNib() { super.awakeFromNib() // Initialization code } override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) // Configure the view for the selected state } @IBAction func valueChanged(_ sender: UIStepper) { valueChangedListener?(sender); setValue(stepperView.value); } func setValue(_ value: Double) { stepperView.value = value; if updateLabel != nil { labelView.text = updateLabel!(value); } } func reset() { cancellables.removeAll(); } func assign(from publisher: AnyPublisher, labelGenerator: ((Double)->String)? = nil) { publisher.removeDuplicates().assign(to: \.value, on: stepperView).store(in: &cancellables); if labelGenerator != nil { publisher.map(labelGenerator!).assign(to: \.text, on: labelView).store(in: &cancellables); } } func assign(from publisher: AnyPublisher, labelGenerator: ((Int)->String)? = nil) { publisher.map({ Double($0) }).removeDuplicates().assign(to: \.value, on: stepperView).store(in: &cancellables); if labelGenerator != nil { publisher.map(labelGenerator!).assign(to: \.text, on: labelView).store(in: &cancellables); } } func sink(to keyPath: ReferenceWritableKeyPath, on object: Root) { stepperView.publisher(for: \.value).removeDuplicates().assign(to: keyPath, on: object).store(in: &cancellables); } func sink(to keyPath: ReferenceWritableKeyPath, on object: Root) { stepperView.publisher(for: \.value).map({ Int($0) }).removeDuplicates().assign(to: keyPath, on: object).store(in: &cancellables); } func bind(_ fn: (StepperTableViewCell)->Void) { reset(); fn(self); } } ================================================ FILE: SiskinIM/ui/SwitchTableViewCell.swift ================================================ // // SwitchTableViewCell.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Combine class SwitchTableViewCell: UITableViewCell { @IBOutlet var switchView: UISwitch! var valueChangedListener: ((UISwitch) -> Void)?; private var cancellables: Set = []; private let subject = PassthroughSubject(); override func awakeFromNib() { super.awakeFromNib() // Initialization code } override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) // Configure the view for the selected state } @IBAction func valueChanged(_ sender: UISwitch) { valueChangedListener?(sender); subject.send(sender.isOn); } func reset() { cancellables.removeAll(); } func assign(from publisher: AnyPublisher) { publisher.removeDuplicates().assign(to: \.isOn, on: switchView).store(in: &cancellables); } func sink(to keyPath: ReferenceWritableKeyPath, on object: Root) { subject.removeDuplicates().assign(to: keyPath, on: object).store(in: &cancellables); } func sink(map: @escaping (Bool)->T, to keyPath: ReferenceWritableKeyPath, on object: Root) { subject.removeDuplicates().map(map).assign(to: keyPath, on: object).store(in: &cancellables); } func bind(_ fn: (SwitchTableViewCell)->Void) { reset(); fn(self); } } ================================================ FILE: SiskinIM/ui/TablePicketViewController.swift ================================================ // // TablePicketView.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Combine class TablePickerViewController: UITableViewController where Value: Equatable { @Published private var selected: Int = 0; private let options: [Value]; private let optionLabels: [String]; private var message: String?; private var footer: String?; private var cancellables: Set = []; init(style: UITableView.Style = .grouped, message: String? = nil, footer: String? = nil, options: [Value], value: Value, labelFn: (Value)->String) { self.message = message; self.footer = footer; self.options = options; self.optionLabels = options.map(labelFn); self.selected = options.firstIndex(where: { $0 == value }) ?? 0; super.init(style: style); } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad(); tableView.register(UITableViewCell.self, forCellReuseIdentifier: "item"); tableView.dataSource = self; tableView.delegate = self; } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { return message; } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { return footer; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return options.count; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "item", for: indexPath); cell.textLabel!.text = optionLabels[indexPath.row]; cell.accessoryType = indexPath.row == selected ? .checkmark : .none; return cell; } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false); if selected != indexPath.row { selected = indexPath.row; tableView.reloadData(); } } func sink(to keyPath: ReferenceWritableKeyPath, on object: Root) { $selected.map({ self.options[$0] }).assign(to: keyPath, on: object).store(in: &cancellables); } func sink(receiveValue: @escaping (Value)->Void) { $selected.map({ self.options[$0] }).sink(receiveValue: receiveValue).store(in: &cancellables); } } extension TablePickerViewController where Value : CustomStringConvertible { convenience init(style: UITableView.Style = .grouped, message: String? = nil, footer: String? = nil, options: [Value], value: Value) { self.init(style: style, message: message, footer: footer, options: options, value: value, labelFn: { v in v.description }); } } protocol TablePickerViewItemsProtocol { var description: String { get }; } ================================================ FILE: SiskinIM/ui/suggestions/MultiContactSelectionView.swift ================================================ // // MultiContactSelectionView.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Combine import Martin class MultiContactSelectionViewController: UITableViewController, UISearchControllerDelegate, MultiContactSearchControllerDelegate { @Published private(set) var selectedItems: [Item] = []; override func viewDidLoad() { super.viewDidLoad(); tableView.rowHeight = UITableView.automaticDimension; tableView.estimatedRowHeight = UITableView.automaticDimension; tableView.register(SelectedItemCellView.self, forCellReuseIdentifier: "selectedItem"); let searchResult = SearchResultController(); searchResult.delegate = self; navigationItem.title = NSLocalizedString("Select contacts", comment: "title for multiple contact selection") navigationItem.searchController = UISearchController(searchResultsController: searchResult); navigationItem.searchController?.searchResultsUpdater = searchResult; navigationItem.searchController?.delegate = self; navigationItem.searchController?.searchBar.placeholder = NSLocalizedString("Search to add…", comment: "placeholder") navigationItem.searchController?.automaticallyShowsSearchResultsController = false; navigationItem.searchController?.showsSearchResultsController = true; navigationItem.searchController?.hidesNavigationBarDuringPresentation = false; navigationItem.hidesSearchBarWhenScrolling = false; navigationItem.searchController?.searchBar.searchBarStyle = .prominent; navigationItem.searchController?.isActive = true; definesPresentationContext = false; navigationItem.searchController?.searchBar.sizeToFit(); } override func numberOfSections(in tableView: UITableView) -> Int { return 1; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return selectedItems.count; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "selectedItem", for: indexPath) as! SelectedItemCellView; let item = selectedItems[indexPath.row]; cell.contact = ContactManager.instance.contact(for: .init(account: item.account, jid: item.jid, type: .buddy)); return cell; } func selected(item: Item) { var items = self.selectedItems; items.append(item); self.selectedItems = items.sorted(); tableView.reloadData(); navigationItem.searchController?.searchBar.searchTextField.text = nil; navigationItem.searchController?.isActive = false; } override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { if editingStyle == .delete { selectedItems.remove(at: indexPath.row); tableView.deleteRows(at: [indexPath], with: .automatic); } } class SearchResultController: UITableViewController, UISearchResultsUpdating { private let dispatcher = QueueDispatcher(label: "searchResultDispatcher"); private var items: [Item] = []; private var cancellables: Set = []; @Published private var queryString: String = ""; weak var delegate: MultiContactSearchControllerDelegate?; override func viewDidLoad() { super.viewDidLoad(); tableView.register(SelectedItemCellView.self, forCellReuseIdentifier: "selectedItem"); DBRosterStore.instance.$items.combineLatest(Settings.$rosterDisplayHiddenGroup, $queryString).throttle(for: 0.1, scheduler: dispatcher.queue, latest: true).map({ items, displayHidden, query -> [RosterItem] in let notHidden = (displayHidden ? items : items.filter({ !$0.groups.contains("Hidden") })); return Array(query.isEmpty ? notHidden : notHidden.filter({ $0.name?.lowercased().contains(query) ?? false || $0.jid.stringValue.lowercased().contains(query)})); }).sink(receiveValue: { [weak self] items in self?.updateItems(items: items); }).store(in: &cancellables); } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); if let rows = tableView.indexPathsForVisibleRows { self.tableView.reloadRows(at: rows, with: .automatic); } } override func numberOfSections(in tableView: UITableView) -> Int { return 1; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return items.count; } func updateItems(items: [RosterItem]) { let oldItems = self.items; let newItems: [Item] = items.compactMap({ item in guard let account = item.context?.userBareJid else { return nil; } guard !item.annotations.contains(where: { $0.type == "mix" }) else { return nil; } return Item(account: account, jid: item.jid.bareJid, displayName: item.name ?? item.jid.stringValue); }).sorted(); let diff = newItems.calculateChanges(from: oldItems); DispatchQueue.main.sync { self.items = newItems; self.tableView.beginUpdates(); self.tableView.deleteRows(at: diff.removed.map({ IndexPath(row: $0, section: 0) }), with: .fade); self.tableView.insertRows(at: diff.inserted.map({ IndexPath(row: $0, section: 0) }), with: .fade); self.tableView.endUpdates(); } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "selectedItem", for: indexPath) as! SelectedItemCellView; let item = items[indexPath.row]; cell.accessoryType = (self.delegate?.selectedItems.contains(item) ?? false) ? .checkmark : .none; cell.contact = ContactManager.instance.contact(for: .init(account: item.account, jid: item.jid, type: .buddy)); return cell; } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { delegate?.selected(item: items[indexPath.row]); } func updateSearchResults(for searchController: UISearchController) { self.queryString = searchController.searchBar.text?.lowercased() ?? ""; } } struct Item: Hashable, Comparable { static func < (lhs: MultiContactSelectionViewController.Item, rhs: MultiContactSelectionViewController.Item) -> Bool { return lhs.displayName.lowercased() < rhs.displayName.lowercased(); } let account: BareJID; let jid: BareJID; let displayName: String; } class SelectedItemCellView: UITableViewCell { let avatarView = AvatarView(frame: .zero); let label = UILabel(); let subtext = UILabel(); let textBox: UIStackView; private var cancellables: Set = []; var contact: Contact? { didSet { cancellables.removeAll(); contact?.$displayName.map({ $0 as String? }).receive(on: DispatchQueue.main).assign(to: \.text, on: label).store(in: &cancellables); if let contact = self.contact { contact.$displayName.combineLatest(contact.avatarPublisher).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] (name, avatar) in self?.avatarView.set(name: name, avatar: avatar); }).store(in: &cancellables); } self.subtext.text = contact?.jid.stringValue } } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { self.textBox = UIStackView(arrangedSubviews: [label, subtext]); super.init(style: style, reuseIdentifier: reuseIdentifier); setup(); } required init?(coder: NSCoder) { self.textBox = UIStackView(arrangedSubviews: [label, subtext]); super.init(coder: coder); setup(); } private func setup() { self.autoresizesSubviews = true; self.avatarView.layer.masksToBounds = true; textBox.axis = .vertical; textBox.alignment = .fill; textBox.distribution = .fill; avatarView.translatesAutoresizingMaskIntoConstraints = false; textBox.translatesAutoresizingMaskIntoConstraints = false; addSubview(avatarView); addSubview(textBox); label.font = SelectedItemCellView.labelViewFont(); label.adjustsFontForContentSizeCategory = true; subtext.font = UIFont.preferredFont(forTextStyle: .caption1) subtext.adjustsFontForContentSizeCategory = true; subtext.textColor = UIColor.secondaryLabel; NSLayoutConstraint.activate([ self.avatarView.widthAnchor.constraint(equalTo: self.avatarView.heightAnchor), self.avatarView.heightAnchor.constraint(equalToConstant: 40), self.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor, constant: -10), self.safeAreaLayoutGuide.topAnchor.constraint(equalTo: avatarView.topAnchor, constant: -6), self.safeAreaLayoutGuide.bottomAnchor.constraint(greaterThanOrEqualTo: avatarView.bottomAnchor, constant: 6), avatarView.trailingAnchor.constraint(equalTo: textBox.leadingAnchor, constant: -8), self.centerYAnchor.constraint(equalTo: textBox.centerYAnchor), self.topAnchor.constraint(greaterThanOrEqualTo: textBox.topAnchor, constant: -6), self.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: textBox.trailingAnchor, constant: 10) ]) } static func labelViewFont() -> UIFont { let preferredFont = UIFont.preferredFont(forTextStyle: .subheadline); let fontDescription = preferredFont.fontDescriptor.withSymbolicTraits(.traitBold)!; return UIFont(descriptor: fontDescription, size: preferredFont.pointSize); } } } protocol MultiContactSearchControllerDelegate: AnyObject { var selectedItems: [MultiContactSelectionViewController.Item] { get } func selected(item: MultiContactSelectionViewController.Item); } ================================================ FILE: SiskinIM/util/AccountManager.swift ================================================ // // AccountManager.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Security import Shared import Martin import Combine open class AccountManager { private static let dispatcher = QueueDispatcher(label: "AccountManager"); private static var accounts: [BareJID: Account] = [:]; static let accountEventsPublisher = PassthroughSubject(); static var defaultAccount: BareJID? { get { return BareJID(Settings.defaultAccount); } set { Settings.defaultAccount = newValue?.stringValue; } } public static let saltedPasswordCache = AccountManagerScramSaltedPasswordCache(); static func getActiveAccounts() -> [Account] { return getAccounts().compactMap({ jid -> Account? in guard let account = getAccount(for: jid), account.active else { return nil; } return account; }); } static func getAccounts() -> [BareJID] { self.dispatcher.sync { guard accounts.isEmpty else { return Array(accounts.keys).sorted(by: { (j1, j2) -> Bool in j1.stringValue.compare(j2.stringValue) == .orderedAscending; }); } let query = [ String(kSecClass) : kSecClassGenericPassword, String(kSecMatchLimit) : kSecMatchLimitAll, String(kSecReturnAttributes) : kCFBooleanTrue as Any, String(kSecAttrService) : "xmpp" ] as [String : Any]; var result: CFTypeRef?; guard SecItemCopyMatching(query as CFDictionary, &result) == noErr else { return []; } guard let results = result as? [[String: NSObject]] else { return []; } let accounts = results.filter({ $0[kSecAttrAccount as String] != nil}).map { item -> BareJID in return BareJID(item[kSecAttrAccount as String] as! String); }.sorted(by: { (j1, j2) -> Bool in j1.stringValue.compare(j2.stringValue) == .orderedAscending }); for account in accounts { if let item = getAccountInt(for: account) { self.accounts[account] = item; } } return accounts; } } static func getAccount(for jid: BareJID) -> Account? { return self.dispatcher.sync { return self.accounts[jid]; } } private static func getAccountInt(for jid: BareJID) -> Account? { let query = AccountManager.getAccountQuery(jid.stringValue); var result: CFTypeRef?; guard SecItemCopyMatching(query as CFDictionary, &result) == noErr else { return nil; } guard let r = result as? [String: NSObject] else { return nil; } var dict: [String: Any]? = nil; if let data = r[String(kSecAttrGeneric)] as? NSData { dict = NSKeyedUnarchiver.unarchiveObject(with: data as Data) as? [String: Any]; } return Account(name: jid, data: dict); } static func getAccountPassword(for account: BareJID) -> String? { let query = AccountManager.getAccountQuery(account.stringValue, withData: kSecReturnData); var result: CFTypeRef?; guard SecItemCopyMatching(query as CFDictionary, &result) == noErr else { return nil; } guard let data = result as? Data else { return nil; } return String(data: data, encoding: .utf8); } static func save(account toSave: Account, reconnect: Bool = true) throws { try self.dispatcher.sync { var account = toSave; var query = AccountManager.getAccountQuery(account.name.stringValue); query.removeValue(forKey: String(kSecMatchLimit)); query.removeValue(forKey: String(kSecReturnAttributes)); var update: [String: Any] = [ kSecAttrGeneric as String: try! NSKeyedArchiver.archivedData(withRootObject: account.data, requiringSecureCoding: false), kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock ]; if let newPassword = account.newPassword { update[kSecValueData as String] = newPassword.data(using: .utf8)!; } if getAccount(for: account.name) == nil { query.merge(update) { (v1, v2) -> Any in return v1; } if let error = AccountManagerError(status: SecItemAdd(query as CFDictionary, nil)) { throw error; } } else { if let error = AccountManagerError(status: SecItemUpdate(query as CFDictionary, update as CFDictionary)) { throw error; } } if account.newPassword != nil { account.saltedPassword = nil; } account.newPassword = nil; if defaultAccount == nil { defaultAccount = account.name; } self.accounts[account.name] = account; DispatchQueue.main.async { self.accountEventsPublisher.send(account.active ? .enabled(account, reconnect) : .disabled(account)); } } } static func deleteAccount(for jid: BareJID) throws { guard let account = getAccount(for: jid) else { return; } try delete(account: account); } static func delete(account: Account) throws { try dispatcher.sync { var query = AccountManager.getAccountQuery(account.name.stringValue); query.removeValue(forKey: String(kSecMatchLimit)); query.removeValue(forKey: String(kSecReturnAttributes)); if let error = AccountManagerError(status: SecItemDelete(query as CFDictionary)) { throw error; } self.accounts.removeValue(forKey: account.name); NotificationEncryptionKeys.set(key: nil, for: account.name); DispatchQueue.main.async { self.accountEventsPublisher.send(.removed(account)); } } } fileprivate static func getAccountQuery(_ name:String, withData:CFString = kSecReturnAttributes) -> [String: Any] { return [ String(kSecClass) : kSecClassGenericPassword, String(kSecMatchLimit) : kSecMatchLimitOne, String(withData) : kCFBooleanTrue!, String(kSecAttrService) : "xmpp" as NSObject, String(kSecAttrAccount) : name as NSObject ]; } enum Event { case enabled(Account,Bool) case disabled(Account) case removed(Account) } struct AccountManagerError: LocalizedError, CustomDebugStringConvertible { let status: OSStatus; let message: String?; var errorDescription: String? { return "\(NSLocalizedString("It was not possible to modify account.", comment: "error description message"))\n\(message ?? "\(NSLocalizedString("Error code", comment: "error description message - detail")): \(status)")"; } var failureReason: String? { return message; } var recoverySuggestion: String? { return NSLocalizedString("Try again. If removal failed, try accessing Keychain to update account credentials manually.", comment: "error recovery suggestion"); } var debugDescription: String { return "AccountManagerError(status: \(status), message: \(message ?? "nil"))"; } init?(status: OSStatus) { guard status != noErr else { return nil; } self.status = status; message = SecCopyErrorMessageString(status, nil) as String?; } } struct Account { public var state = CurrentValueSubject(.disconnected()); fileprivate var data:[String: Any]; fileprivate var newPassword: String?; public let name: BareJID; public var active:Bool { get { return (data["active"] as? Bool) ?? true; } set { data["active"] = newValue as AnyObject?; } } public var password:String? { get { guard newPassword == nil else { return newPassword; } return AccountManager.getAccountPassword(for: name); } set { self.newPassword = newValue; } } public var nickname: String? { get { guard let nick = data["nickname"] as? String, !nick.isEmpty else { return name.localPart; } return nick; } set { if newValue == nil { data.removeValue(forKey: "nickname"); } else { data["nickname"] = newValue; } } } public var server:String? { get { return data["serverHost"] as? String; } set { if newValue != nil { data["serverHost"] = newValue as AnyObject?; } else { data.removeValue(forKey: "serverHost"); } } } public var rosterVersion:String? { get { return data["rosterVersion"] as? String; } set { if newValue != nil { data["rosterVersion"] = newValue as AnyObject?; } else { data.removeValue(forKey: "rosterVersion"); } } } public var presenceDescription: String? { get { return data["presenceDescription"] as? String; } set { if newValue != nil { data["presenceDescription"] = newValue as AnyObject?; } else { data.removeValue(forKey: "presenceDescription"); } } } public var pushNotifications: Bool { get { return (data["pushNotifications"] as? Bool) ?? false; } set { data["pushNotifications"] = newValue as AnyObject?; } } public var pushSettings: SiskinPushNotificationsModule.PushSettings? { get { return SiskinPushNotificationsModule.PushSettings(dictionary: data["push"] as? [String: Any]); } set { data["push"] = newValue?.dictionary(); data.removeValue(forKey: "pushServiceJid"); data.removeValue(forKey: "pushServiceNode"); } } public var serverCertificate: ServerCertificateInfo? { get { return data["serverCert"] as? ServerCertificateInfo; } set { if newValue != nil { data["serverCert"] = newValue; } else { data.removeValue(forKey: "serverCert"); } } } public var saltedPassword: SaltEntry? { get { return SaltEntry(dict: data["saltedPassword"] as? [String: Any]); } set { if newValue != nil { data["saltedPassword"] = newValue!.dictionary() as AnyObject?; } else { data.removeValue(forKey: "saltedPassword"); } } } public var disableTLS13: Bool { get { return data["disableTLS13"] as? Bool ?? false; } set { if newValue { data["disableTLS13"] = newValue; } else { data.removeValue(forKey: "disableTLS13"); } } } public var endpoint: SocketConnectorNetwork.Endpoint? { get { guard let values = data["endpoint"] as? [String: Any], let protoStr = values["proto"] as? String, let proto = ConnectorProtocol(rawValue: protoStr), let host = values["host"] as? String, let port = values["port"] as? Int else { return nil; } return SocketConnectorNetwork.Endpoint(proto: proto, host: host, port: port); } set { if let value = newValue { data["endpoint"] = [ "proto": value.proto.rawValue, "host": value.host, "port": value.port ]; } else { data.removeValue(forKey: "endpoint"); } } } public init(name: BareJID, data: [String: Any]? = nil) { self.name = name; self.data = data ?? [String: Any](); } public mutating func acceptCertificate(_ certData: SslCertificateInfo?) { guard let data = certData else { self.serverCertificate = nil; return; } self.serverCertificate = ServerCertificateInfo(sslCertificateInfo: data, accepted: true); } } open class SaltEntry { public let id: String; public let value: [UInt8]; convenience init?(dict: [String: Any]?) { guard let id = dict?["id"] as? String, let value = dict?["value"] as? [UInt8] else { return nil; } self.init(id: id, value: value); } public init(id: String, value: [UInt8]) { self.id = id; self.value = value; } open func dictionary() -> [String: Any] { return ["id": id, "value": value]; } } } ================================================ FILE: SiskinIM/util/AccountManagerScramSaltedPasswordCache.swift ================================================ // // AccountManagerScramSaltedPasswordCache.swift // // Siskin IM // Copyright (C) 2018 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin open class AccountManagerScramSaltedPasswordCache: ScramSaltedPasswordCacheProtocol { public init() { } public func getSaltedPassword(for context: Context, id: String) -> [UInt8]? { guard let salted = AccountManager.getAccount(for: context.userBareJid)?.saltedPassword else { return nil; } return salted.id == id ? salted.value : nil; } public func store(for context: Context, id: String, saltedPassword: [UInt8]) { setSaltedPassword(AccountManager.SaltEntry(id: id, value: saltedPassword), for: context); } public func clearCache(for context: Context) { setSaltedPassword(nil, for: context) } fileprivate func setSaltedPassword(_ value: AccountManager.SaltEntry?, for context: Context) { guard var account = AccountManager.getAccount(for: context.userBareJid) else { return; } account.saltedPassword = value; try? AccountManager.save(account: account, reconnect: false); } } ================================================ FILE: SiskinIM/util/AppStoryboard.swift ================================================ // // AppStoryboard.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit enum AppStoryboard: String { case Main = "Main" case VoIP = "VoIP" case Groupchat = "Groupchat" case Info = "Info" case Settings = "Settings" case Account = "Account" var instance: UIStoryboard { return UIStoryboard(name: self.rawValue, bundle: Bundle.main); } func instantiateViewController(withIdentifier identifier: String) -> UIViewController { return instance.instantiateViewController(withIdentifier: identifier); } func instantiateViewController(ofClass: T.Type) -> T { let storyboardID = ofClass.storyboardID; return instance.instantiateViewController(withIdentifier: storyboardID) as! T; } } extension UIViewController { class var storyboardID: String { return "\(self)"; } static func instantiate(fromAppStoryboard: AppStoryboard) -> Self { return fromAppStoryboard.instantiateViewController(ofClass: self); } } ================================================ FILE: SiskinIM/util/Array+IndexChanges.swift ================================================ // // Array+IndexChanges.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation extension Array { public struct IndexSetChanges { let removed: IndexSet; let inserted: IndexSet; } } extension Array where Element: Hashable { func calculateChanges(from source: Array) -> IndexSetChanges { let diff = self.difference(from: source); let removed = diff.removals.map({ change -> Int in switch change { case .insert(let offset, _, _): return offset; case .remove(let offset, _, _): return offset; } }) let inserted = diff.insertions.map({ change -> Int in switch change { case .insert(let offset, _, _): return offset; case .remove(let offset, _, _): return offset; } }) return .init(removed: IndexSet(removed), inserted: IndexSet(inserted)); } } ================================================ FILE: SiskinIM/util/AudioSession.swift ================================================ // // AudioSession.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import AVFoundation class AudioSesion { private(set) var outputMode: AudioOutputMode = .automatic; private let hasLoudSpeaker: Bool; private let preferSpeaker: Bool; init(preferSpeaker: Bool) { self.preferSpeaker = preferSpeaker; self.hasLoudSpeaker = UIDevice.current.model.lowercased().contains("iphone"); NotificationCenter.default.addObserver(self, selector: #selector(audioRouteChanged), name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance()) set(outputMode: .automatic) } func set(outputMode: AudioOutputMode) { self.outputMode = outputMode; try? self.updateCurrentAudioRoute(); } enum Mode { case voiceChat case videoChat } enum AudioOutputMode: Equatable { case automatic case builtin case speaker case custom(AVAudioSessionPortDescription) var label: String { switch self { case .automatic: return NSLocalizedString("Automatic", comment: "audio output selection"); case .builtin: return UIDevice.current.localizedModel; case .speaker: return NSLocalizedString("Speaker", comment: "audio output label"); case .custom(let port): return port.portName; } } var icon: UIImage? { switch self { case .automatic: return nil; case .builtin: if UIDevice.current.model.lowercased().contains("iphone") { return UIImage(systemName: "iphone"); } if UIDevice.current.model.lowercased().contains("ipad") { return UIImage(systemName: "ipad"); } return nil; case .speaker: return UIImage(systemName: "speaker.wave.2"); case .custom(let port): return port.portType.icon; } } } func availableAudioPorts() -> [AudioOutputMode] { var result: [AudioOutputMode] = []; let availableInputs = AVAudioSession.sharedInstance().availableInputs ?? []; for it in availableInputs { switch it.portType { case .builtInMic: result.append(.builtin); if hasLoudSpeaker { result.append(.speaker); } default: result.append(.custom(it)); } } return result; } @objc func audioRouteChanged(_ notification: Notification) { guard let value = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt, let reason = AVAudioSession.RouteChangeReason(rawValue: value) else { return; } DispatchQueue.main.async { [weak self] in guard let that = self else { return; } let prevMode = that.outputMode; switch reason { case .newDeviceAvailable, .oldDeviceUnavailable: that.outputMode = .automatic; default: break; } if prevMode != that.outputMode { try? that.updateCurrentAudioRoute(); } } } func updateCurrentAudioRoute() throws { switch outputMode { case .builtin: try AVAudioSession.sharedInstance().setPreferredInput(nil); try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none); case .speaker: try AVAudioSession.sharedInstance().setPreferredInput(nil); try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker); case .custom(let port): try AVAudioSession.sharedInstance().setPreferredInput(port); try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none); case .automatic: try AVAudioSession.sharedInstance().setPreferredInput(nil); // we should use speaker by default if there is no headphone or car audio and there is a video being sent let useSpeaker = hasLoudSpeaker && preferSpeaker && (!isCarAudioPluggedIn()) && (!isHeadsetPluggedIn()) try AVAudioSession.sharedInstance().overrideOutputAudioPort(useSpeaker ? .speaker : .none); } } private func isHeadsetPluggedIn() -> Bool { return AVAudioSession.sharedInstance().currentRoute.outputs.contains(where: { desc -> Bool in switch desc.portType { case .headphones, .bluetoothA2DP, .bluetoothHFP: return true; default: return false; } }); } private func isCarAudioPluggedIn() -> Bool { return AVAudioSession.sharedInstance().currentRoute.outputs.contains(where: { desc -> Bool in switch desc.portType { case .carAudio: return true; default: return false; } }); } } extension AVAudioSession.Port { var icon: UIImage? { switch self { case .builtInMic: return UIImage(systemName: "mic.fill") case .builtInSpeaker: return UIImage(systemName: "speaker.wave.2") case .headsetMic, .headphones: return UIImage(systemName: "headphones"); case .bluetoothLE, .bluetoothHFP, .bluetoothA2DP: return UIImage(systemName: "wave.3.right"); case .carAudio: return UIImage(systemName: "car.fill"); default: return nil; } } } ================================================ FILE: SiskinIM/util/AvatarManager.swift ================================================ // // AvatarManager.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine import TigaseLogging struct AvatarWeakRef { weak var avatar: Avatar?; } public class Avatar { private enum AvatarResult: Equatable { case notReady case ready(UIImage?) } private let key: Key; public var hash: String? { didSet { if let hash = hash { AvatarManager.instance.avatar(withHash: hash, completionHandler: { result in guard hash == self.hash else { return; } switch result { case .success(let avatar): self.avatarSubject.send(.ready(avatar)); case .failure(_): self.avatarSubject.send(.ready(nil)); } }); } else { self.avatarSubject.send(.ready(nil)); } } } private let avatarSubject = CurrentValueSubject(.notReady); public let avatarPublisher: AnyPublisher; init(key: Key) { self.key = key; self.avatarPublisher = avatarSubject.filter({ .notReady != $0 }).map({ switch $0 { case .notReady: return nil; case .ready(let image): return image; } }).removeDuplicates().eraseToAnyPublisher(); } deinit { AvatarManager.instance.releasePublisher(for: key); } struct Key: Hashable, CustomStringConvertible { let account: BareJID; let jid: BareJID; let mucNickname: String?; var description: String { return "Key(account: \(account), jid: \(jid), nick: \(mucNickname ?? ""))"; } } } class AvatarManager { public static let AVATAR_CHANGED = Notification.Name("avatarChanged"); public static let AVATAR_FOR_HASH_CHANGED = Notification.Name("avatarForHashChanged"); public static let instance = AvatarManager(); fileprivate let store = AvatarStore(); public var defaultAvatar: UIImage { return UIImage(named: "defaultAvatar")!; } public var defaultGroupchatAvatar: UIImage { return UIImage(named: "defaultGroupchatAvatar")!; } private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AvatarManager"); fileprivate var dispatcher = QueueDispatcher(label: "avatar_manager", attributes: .concurrent); public init() { NotificationCenter.default.addObserver(self, selector: #selector(vcardUpdated), name: DBVCardStore.VCARD_UPDATED, object: nil); } private var avatars: [Avatar.Key: AvatarWeakRef] = [:]; open func avatarPublisher(for key: Avatar.Key) -> Avatar { return dispatcher.sync(flags: .barrier) { guard let avatar = avatars[key]?.avatar else { let avatar = Avatar(key: key); DispatchQueue.global(qos: .userInitiated).async { avatar.hash = self.avatarHash(for: key.jid, on: key.account, withNickname: key.mucNickname); } avatars[key] = AvatarWeakRef(avatar: avatar); return avatar; } return avatar; } } open func existingAvatarPublisher(for key: Avatar.Key) -> Avatar? { return dispatcher.sync { return avatars[key]?.avatar; } } open func releasePublisher(for key: Avatar.Key) { dispatcher.async(flags: .barrier) { self.avatars.removeValue(forKey: key); } } private func avatarHash(for jid: BareJID, on account: BareJID, withNickname nickname: String?) -> String? { if let nickname = nickname { guard let room = DBChatStore.instance.conversation(for: account, with: jid) as? Room else { return nil; } guard let occupant = room.occupant(nickname: nickname) else { return nil; } guard let hash = occupant.presence.vcardTempPhoto else { guard let occuapntJid = occupant.jid?.bareJid else { return nil; } return store.avatarHash(for: occuapntJid, on: account).first?.hash; } return hash; } else { return store.avatarHash(for: jid, on: account).first?.hash;//avatars(on: account).avatarHash(for: jid); } } open func avatar(for jid: BareJID, on account: BareJID) -> UIImage? { guard let hash = store.avatarHash(for: jid, on: account).first?.hash else { return nil; } return store.avatar(for: hash); } open func hasAvatar(withHash hash: String) -> Bool { return store.hasAvatarFor(hash: hash); } open func avatar(withHash hash: String) -> UIImage? { return store.avatar(for: hash); } open func avatar(withHash hash: String, completionHandler: @escaping (Result)->Void) { store.avatar(for: hash, completionHandler: completionHandler); } open func storeAvatar(data: Data) -> String { let hash = Digest.sha1.digest(toHex: data)!; self.store.storeAvatar(data: data, for: hash); NotificationCenter.default.post(name: AvatarManager.AVATAR_FOR_HASH_CHANGED, object: hash); return hash; } open func updateAvatar(hash: String, forType type: AvatarType, forJid jid: BareJID, on account: BareJID) { self.store.updateAvatarHash(for: jid, on: account, hash: .init(type: type, hash: hash), completionHandler: { result in switch result { case .notChanged: break; case .noAvatar: self.avatarUpdated(hash: nil, for: jid, on: account, withNickname: nil); case .newAvatar(let hash): self.avatarUpdated(hash: hash, for: jid, on: account, withNickname: nil); } }) } public func avatarUpdated(hash: String?, for jid: BareJID, on account: BareJID, withNickname nickname: String?) { if let avatar = self.existingAvatarPublisher(for: .init(account: account, jid: jid, mucNickname: nickname)) { if hash == nil, let nickname = nickname { if let room = DBChatStore.instance.conversation(for: account, with: jid) as? Room, let occupantJid = room.occupant(nickname: nickname)?.jid?.bareJid { avatar.hash = store.avatarHash(for: occupantJid, on: account).first?.hash; } else { avatar.hash = hash; } } else { avatar.hash = hash; } } } open func avatarHashChanged(for jid: BareJID, on account: BareJID, type: AvatarType, hash: String) { if hasAvatar(withHash: hash) { updateAvatar(hash: hash, forType: type, forJid: jid, on: account); } else { switch type { case .vcardTemp: VCardManager.instance.refreshVCard(for: jid, on: account, completionHandler: nil); case .pepUserAvatar: self.retrievePepUserAvatar(for: jid, on: account, hash: hash); } } } @objc func vcardUpdated(_ notification: Notification) { guard let vcardItem = notification.object as? DBVCardStore.VCardItem else { return; } DispatchQueue.global().async { guard let photo = vcardItem.vcard.photos.first else { return; } AvatarManager.fetchData(photo: photo) { data in guard data != nil else { return; } let hash = self.storeAvatar(data: data!); self.updateAvatar(hash: hash, forType: .vcardTemp, forJid: vcardItem.jid, on: vcardItem.account); } } } func retrievePepUserAvatar(for jid: BareJID, on account: BareJID, hash: String) { guard let pepModule = XmppService.instance.getClient(for: account)?.module(.pepUserAvatar) else { return; } pepModule.retrieveAvatar(from: jid, itemId: hash, completionHandler: { result in switch result { case .success((let hash, let data)): self.store.storeAvatar(data: data, for: hash); self.updateAvatar(hash: hash, forType: .pepUserAvatar, forJid: jid, on: account); case .failure(let error): self.logger.error("could not retrieve avatar from: \(jid), item id: \(hash), got error: \(error.description, privacy: .public)"); } }); } static func fetchData(photo: VCard.Photo, completionHandler: @escaping (Data?)->Void) { if let data = photo.binval { completionHandler(Data(base64Encoded: data, options: Data.Base64DecodingOptions.ignoreUnknownCharacters)); } else if let uri = photo.uri { if uri.hasPrefix("data:image") && uri.contains(";base64,") { let idx = uri.index(uri.firstIndex(of: ",")!, offsetBy: 1); let data = String(uri[idx...]); completionHandler(Data(base64Encoded: data, options: Data.Base64DecodingOptions.ignoreUnknownCharacters)); } else if let url = URL(string: uri) { let task = URLSession.shared.dataTask(with: url) { (data, response, err) in completionHandler(data); } task.resume(); } else { completionHandler(nil); } } else { completionHandler(nil); } } public func clearCache() { store.clearCache(); } } enum AvatarResult { case some(AvatarType, String) case none } ================================================ FILE: SiskinIM/util/AvatarStore.swift ================================================ // // AvatarStore.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Shared import Martin import TigaseSQLite3 extension Query { static let avatarFindHash = Query("SELECT type, hash FROM avatars_cache WHERE account = :account AND jid = :jid"); static let avatarDeleteHash = Query("DELETE FROM avatars_cache WHERE jid = :jid AND account = :account AND (:type IS NULL OR type = :type)"); static let avatarInsertHash = Query("INSERT INTO avatars_cache (jid, account, hash, type) VALUES (:jid,:account,:hash,:type)"); } open class AvatarStore { fileprivate let dispatcher = QueueDispatcher(label: "avatar_store", attributes: .concurrent); fileprivate let cacheDirectory: URL; private let cache = NSCache(); public init() { cacheDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.siskinim.shared")!.appendingPathComponent("Library", isDirectory: true).appendingPathComponent("Caches", isDirectory: true).appendingPathComponent("avatars", isDirectory: true); let oldCacheDirectory = try! FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("avatars", isDirectory: true); if FileManager.default.fileExists(atPath: oldCacheDirectory.path) { if !FileManager.default.fileExists(atPath: cacheDirectory.path) { let parentDir = cacheDirectory.deletingLastPathComponent(); // we need to create parent directory if it does not exist if !FileManager.default.fileExists(atPath: parentDir.path) { try! FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil); } // we need to move cache try! FileManager.default.moveItem(at: oldCacheDirectory, to: cacheDirectory); } } else { // nothing to move, let's check if destinatin exists if !FileManager.default.fileExists(atPath: cacheDirectory.path) { try! FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true, attributes: nil); } } } open func hasAvatarFor(hash: String) -> Bool { return dispatcher.sync { return FileManager.default.fileExists(atPath: self.cacheDirectory.appendingPathComponent(hash).path); } } open func avatarHash(for jid: BareJID, on account: BareJID) -> [AvatarHash] { return dispatcher.sync { return try! Database.main.reader({ database in try database.select(query: .avatarFindHash, params: ["account": account, "jid": jid]).mapAll({ cursor -> AvatarHash? in guard let type = AvatarType(rawValue: cursor["type"]!), let hash: String = cursor["hash"] else { return nil; } return AvatarHash(type: type, hash: hash); }); }); } } open func avatar(for hash: String) -> UIImage? { return dispatcher.sync { if let image = cache.object(forKey: hash as NSString) { return image; } if let image = UIImage(contentsOfFile: cacheDirectory.appendingPathComponent(hash).path) { cache.setObject(image, forKey: hash as NSString); return image; } return nil; } } func avatar(for hash: String, completionHandler: @escaping (Result)->Void) { dispatcher.async { if let image = self.cache.object(forKey: hash as NSString) { completionHandler(.success(image)); return; } if let image = UIImage(contentsOfFile: self.cacheDirectory.appendingPathComponent(hash).path) { self.cache.setObject(image, forKey: hash as NSString); completionHandler(.success(image)); return; } completionHandler(.failure(.conflict)) } } open func removeAvatar(for hash: String) { dispatcher.sync(flags: .barrier) { try? FileManager.default.removeItem(at: cacheDirectory.appendingPathComponent(hash)); cache.removeObject(forKey: hash as NSString); } } open func storeAvatar(data: Data, for hash: String) { dispatcher.async(flags: .barrier) { if !FileManager.default.fileExists(atPath: self.cacheDirectory.path) { try? FileManager.default.createDirectory(at: self.cacheDirectory, withIntermediateDirectories: true, attributes: nil); } _ = FileManager.default.createFile(atPath: self.cacheDirectory.appendingPathComponent(hash).path, contents: data, attributes: nil); } } public enum AvatarUpdateResult { case newAvatar(String) case notChanged case noAvatar } open func removeAvatarHash(for jid: BareJID, on account: BareJID, type: AvatarType, completionHandler: @escaping ()->Void) { dispatcher.async { try! Database.main.writer({ database in try database.delete(query: .avatarDeleteHash, params: ["account": account, "jid": jid, "type": type.rawValue]); }); completionHandler(); } } open func updateAvatarHash(for jid: BareJID, on account: BareJID, hash: AvatarHash, completionHandler: @escaping (AvatarUpdateResult)->Void ) { dispatcher.async(flags: .barrier) { let oldHashes = self.avatarHash(for: jid, on: account); guard !oldHashes.contains(hash) else { completionHandler(.notChanged); return; } try! Database.main.writer({ database in try database.delete(query: .avatarDeleteHash, params: ["account": account, "jid": jid, "type": hash.type.rawValue]); try database.insert(query: .avatarInsertHash, params: ["account": account, "jid": jid, "type": hash.type.rawValue, "hash": hash.hash]); }) if oldHashes.isEmpty { completionHandler(.newAvatar(hash.hash)); } else if let first = oldHashes.first, first >= hash { completionHandler(.newAvatar(hash.hash)); } else { completionHandler(.notChanged); } } } public func clearCache() { cache.removeAllObjects(); } } public struct AvatarHash: Comparable, Equatable { public static func < (lhs: AvatarHash, rhs: AvatarHash) -> Bool { return lhs.type < rhs.type; } let type: AvatarType; let hash: String; } public enum AvatarType: String, Comparable { public static func < (lhs: AvatarType, rhs: AvatarType) -> Bool { return lhs.value < rhs.value; } case vcardTemp case pepUserAvatar private var value: Int { switch self { case .vcardTemp: return 2; case .pepUserAvatar: return 1; } } public static let ALL: [AvatarType] = [.pepUserAvatar, .vcardTemp]; } ================================================ FILE: SiskinIM/util/ContactManager.swift ================================================ // // ContactManager.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import UIKit import Combine public class Contact: DisplayableIdWithKeyProtocol { public let key: Key; public var account: BareJID { return key.account; } public var jid: BareJID { return key.jid; } @Published public var displayName: String; public var displayNamePublisher: Published.Publisher { return $displayName; } @Published public var status: Presence.Show?; public var statusPublisher: Published.Publisher { return $status; } @Published public var description: String?; public var descriptionPublisher: Published.Publisher { return $description; } public let avatar: Avatar; public var avatarPublisher: AnyPublisher { return avatar.avatarPublisher; } public init(key: Key, displayName: String, status: Presence.Show?) { self.key = key; self.displayName = displayName; self.status = status; self.avatar = AvatarManager.instance.avatarPublisher(for: .init(account: key.account, jid: key.jid, mucNickname: nil)); } deinit { ContactManager.instance.release(key); } public struct Key: Hashable, Equatable { public let account: BareJID; public let jid: BareJID; public let type: KeyType } public enum KeyType: Hashable, Equatable { case buddy case occupant(nickname: String) case participant(id: String) } public struct Weak { weak var contact: Contact?; } } public class ContactManager { public let dispatcher = QueueDispatcher(label: "contactManager"); public static let instance = ContactManager(); private var items: [Contact.Key: Contact.Weak] = [:]; private var cancellables: Set = []; public init() { PresenceStore.instance.bestPresenceEvents.receive(on: dispatcher.queue).sink(receiveValue: { [weak self] event in self?.update(presence: event.presence, for: .init(account: event.account, jid: event.jid, type: .buddy)); }).store(in: &cancellables); } public func contact(for key: Contact.Key) -> Contact { return dispatcher.sync(execute: { if let contact = self.items[key]?.contact { return contact; } else { let contact = Contact(key: key, displayName: self.name(for: key), status: self.status(for: key)); self.items[key] = Contact.Weak(contact: contact); return contact; } }); } public func existingContact(for key: Contact.Key) -> Contact? { return dispatcher.sync(execute: { return self.items[key]?.contact; }) } public func update(name: String?, for key: Contact.Key) { dispatcher.async { guard let contact = self.items[key]?.contact else { return; } DispatchQueue.main.async { contact.displayName = name ?? key.jid.stringValue; } } } public func update(presence: Presence?, for key: Contact.Key) { dispatcher.async { guard let contact = self.items[key]?.contact else { return; } DispatchQueue.main.async { contact.status = presence?.show; contact.description = presence?.status; } } } private func status(for key: Contact.Key) -> Presence.Show? { switch key.type { case .buddy: return PresenceStore.instance.bestPresence(for: key.jid, on: key.account)?.show; case .participant(let id): return PresenceStore.instance.bestPresence(for: BareJID(localPart: "\(id)#\(key.jid.localPart ?? "")", domain: key.jid.domain), on: key.account)?.show; case .occupant(let nickname): return (DBChatStore.instance.conversation(for: key.account, with: key.jid) as? Room)?.occupant(nickname: nickname)?.presence.show; } } private func name(for key: Contact.Key) -> String { switch key.type { case .buddy: return DBRosterStore.instance.item(for: key.account, jid: JID(key.jid))?.name ?? key.jid.stringValue; case .participant(let id): return (DBChatStore.instance.conversation(for: key.account, with: key.jid) as? Channel)?.participant(withId: id)?.nickname ?? id; case .occupant(let nickname): return nickname; } } fileprivate func release(_ key: Contact.Key) { dispatcher.async { if let weak = self.items[key], weak.contact == nil { self.items.removeValue(forKey: key); } } } } ================================================ FILE: SiskinIM/util/CurrentDatePublisher.swift ================================================ // // CurrentDatePublisher.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Combine struct CurrentTimePublisher { private static var cancellable: Cancellable?; public private(set) static var publisher: AnyPublisher = { let publisher = CurrentValueSubject(Date()); cancellable = Timer.publish(every: 30, on: .main, in: .default).autoconnect().assign(to: \.value, on: publisher); return publisher.eraseToAnyPublisher(); }(); } ================================================ FILE: SiskinIM/util/DownloadManager.swift ================================================ // // DownloadManager.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import MobileCoreServices import Martin import Shared class DownloadManager: NSObject { static let instance = DownloadManager(); private let dispatcher = QueueDispatcher(label: "download_manager_queue"); private var itemDownloadInProgress: [Int] = []; private var downloadSession: URLSession!; private var inProgress: [URLSessionDownloadTask: Item] = [:]; private override init() { super.init(); downloadSession = URLSession(configuration: URLSession.shared.configuration, delegate: self, delegateQueue: nil); } func downloadInProgress(for item: ConversationEntry) -> Bool { return dispatcher.sync { return self.itemDownloadInProgress.contains(item.id); } } func download(item: ConversationEntry, url inUrl: String, maxSize: Int64) -> Bool { return dispatcher.sync { guard var url = URL(string: inUrl) else { DBChatHistoryStore.instance.updateItem(for: item.conversation, id: item.id, updateAppendix: { appendix in appendix.state = .error; }); return false; } guard !itemDownloadInProgress.contains(item.id) else { return false; } itemDownloadInProgress.append(item.id); if let hash = Digest.sha1.digest(toHex: inUrl.data(using: .utf8)!), var params = SettingsStore.sharedDefaults.dictionary(forKey: "upload-\(hash)"), let filename = params["name"] as? String { var jids: [BareJID] = (params["jids"] as? [String])?.map({ BareJID($0) }) ?? []; let sharedFileUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.siskinim.shared")!.appendingPathComponent("upload", isDirectory: true).appendingPathComponent(hash, isDirectory: false); var handled = false; if jids.contains(item.conversation.jid) { jids = jids.filter({ (j) -> Bool in return j != item.conversation.jid; }); params["jids"] = jids.map({ $0.stringValue }); _ = DownloadStore.instance.store(sharedFileUrl, filename: filename, with: "\(item.id)"); DBChatHistoryStore.instance.updateItem(for: item.conversation, id: item.id, updateAppendix: { appendix in appendix.filesize = params["size"] as? Int; appendix.mimetype = params["mimeType"] as? String; appendix.filename = filename; appendix.state = .downloaded; }); handled = true; } if jids.isEmpty || !FileManager.default.fileExists(atPath: sharedFileUrl.path) { SettingsStore.sharedDefaults.removeObject(forKey: "upload-\(hash)") if FileManager.default.fileExists(atPath: sharedFileUrl.path) { try! FileManager.default.removeItem(at: sharedFileUrl); } } else { SettingsStore.sharedDefaults.set(params, forKey: "upload-\(hash)"); } guard !handled else { self.itemDownloadInProgress = self.itemDownloadInProgress.filter({ (id) -> Bool in return item.id != id; }); return true; } } var encryptionKey: String? = nil; if url.scheme == "aesgcm", var components = URLComponents(url: url, resolvingAgainstBaseURL: true) { encryptionKey = components.fragment; components.scheme = "https"; components.fragment = nil; if let tmpUrl = components.url { url = tmpUrl; } } retrieveHeaders(session: downloadSession, url: url, completionHandler: { headersResult in switch headersResult { case .success(let suggestedFilename, let expectedSize, let mimeType): let isTooBig = expectedSize > maxSize; DBChatHistoryStore.instance.updateItem(for: item.conversation, id: item.id, updateAppendix: { appendix in appendix.filesize = Int(expectedSize); appendix.mimetype = mimeType; appendix.filename = suggestedFilename; if isTooBig { appendix.state = .tooBig; } }); guard !isTooBig else { self.dispatcher.async { self.itemDownloadInProgress = self.itemDownloadInProgress.filter({ (id) -> Bool in return item.id != id; }); } return; } self.download(session: self.downloadSession, url: url, expectedSize: expectedSize, completionHandler: { result in switch result { case .success((let downloadedUrl, let filename)): var dataConsumer: Cipher.TempFileConsumer?; if let encryptionKey = encryptionKey, let inputStream = InputStream(url: downloadedUrl), encryptionKey.count % 2 == 0 && encryptionKey.count > 64, let size = try? downloadedUrl.resourceValues(forKeys: [.fileSizeKey]).fileSize { let fragmentData = encryptionKey.map { (c) -> UInt8 in return UInt8(c.hexDigitValue ?? 0); }; let ivLen = fragmentData.count - (32 * 2); var iv = Data(); var key = Data(); for i in 0..<(ivLen/2) { iv.append(fragmentData[i*2]*16 + fragmentData[i*2+1]); } for i in (ivLen/2)..<(fragmentData.count/2) { key.append(fragmentData[i*2]*16 + fragmentData[i*2+1]); } let dataProvider = Cipher.FileDataProvider(inputStream: inputStream, fileSize: size, hasAuthTag: true); dataConsumer = Cipher.TempFileConsumer(); let aes = Cipher.AES_GCM(); if !aes.decrypt(iv: iv, key: key, provider: dataProvider, consumer: dataConsumer!) { dataConsumer = nil; } dataConsumer?.close(); } //let id = UUID().uuidString; _ = DownloadStore.instance.store(dataConsumer?.url ?? downloadedUrl, filename: filename, with: "\(item.id)"); DBChatHistoryStore.instance.updateItem(for: item.conversation, id: item.id, updateAppendix: { appendix in appendix.state = .downloaded; }); self.dispatcher.sync { self.itemDownloadInProgress = self.itemDownloadInProgress.filter({ (id) -> Bool in return item.id != id; }); } case .failure(let err): var statusCode = 0; switch err { case .fileSizeMismatch: statusCode = 404; case .responseError(let code): statusCode = code; default: break; } DBChatHistoryStore.instance.updateItem(for: item.conversation, id: item.id, updateAppendix: { appendix in appendix.state = statusCode == 404 ? .gone : .error; }); self.dispatcher.sync { self.itemDownloadInProgress = self.itemDownloadInProgress.filter({ (id) -> Bool in return item.id != id; }); } } }); break; case .failure(let statusCode): DBChatHistoryStore.instance.updateItem(for: item.conversation, id: item.id, updateAppendix: { appendix in appendix.state = statusCode == 404 ? .gone : .error; }); self.dispatcher.async { self.itemDownloadInProgress = self.itemDownloadInProgress.filter({ (id) -> Bool in return item.id != id; }); } } }) return true; } } func download(session: URLSession, url: URL, expectedSize: Int64, completionHandler: @escaping (Result<(URL,String), DownloadError>)->Void) { let request = URLRequest(url: url); let task = session.downloadTask(with: request); inProgress[task] = Item(maxSize: expectedSize, completionHandler: completionHandler); task.resume(); } static func mimeTypeToExtension(mimeType: String) -> String? { let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil) guard let fileUTI = uti?.takeRetainedValue(), let fileExtension = UTTypeCopyPreferredTagWithClass(fileUTI, kUTTagClassFilenameExtension) else { return nil } let extensionString = String(fileExtension.takeRetainedValue()) return extensionString } func retrieveHeaders(session: URLSession, url: URL, completionHandler: @escaping (HeadersResult)->Void) { var request = URLRequest(url: url); request.httpMethod = "HEAD"; session.dataTask(with: request) { (data, resp, error) in guard let response = resp as? HTTPURLResponse else { completionHandler(.failure(statusCode: 500)); return; } switch response.statusCode { case 200: completionHandler(.success(suggestedFilename: response.suggestedFilename, expectedSize: response.expectedContentLength, mimeType: response.mimeType)) default: completionHandler(.failure(statusCode: response.statusCode)); } }.resume(); } class Item { let maxSize: Int64; let completionHandler: (Result<(URL,String), DownloadError>)->Void; init(maxSize: Int64, completionHandler: @escaping (Result<(URL,String), DownloadError>)->Void) { self.completionHandler = completionHandler; self.maxSize = maxSize; } func completed(location: URL, filename: String) { completionHandler(.success((location, filename))); } func completed(withError error: Error?) { guard let err = error else { completionHandler(.failure(.responseError(statusCode: 500))); return; } if (err as NSError).domain == "NSURLErrorDomain" && (err as NSError).code == NSURLErrorCancelled { completionHandler(.failure(.fileSizeMismatch)); } else { completionHandler(.failure(.networkError(error: err))); } } } enum HeadersResult { case success(suggestedFilename: String?, expectedSize: Int64, mimeType: String?) case failure(statusCode: Int) } enum DownloadError: Error { case networkError(error: Error) case responseError(statusCode: Int) case tooBig(size: Int64, mimeType: String?, filename: String?) case badMimeType(mimeType: String?) case fileSizeMismatch } } extension DownloadManager: URLSessionDownloadDelegate { func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { guard let item = dispatcher.sync(execute: { return self.inProgress.removeValue(forKey: downloadTask); }) else { return; } if let filename = downloadTask.response?.suggestedFilename { item.completed(location: location, filename: filename); } else if let mimeType = downloadTask.response?.mimeType, let filenameExt = DownloadManager.mimeTypeToExtension(mimeType: mimeType) { item.completed(location: location, filename: "file.\(filenameExt)"); } else if let uti = try? location.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier, let filenameExt = UTTypeCopyPreferredTagWithClass(uti as CFString, kUTTagClassFilenameExtension)?.takeRetainedValue() as String? { item.completed(location: location, filename: "file.\(filenameExt)"); } else { item.completed(location: location, filename: location.lastPathComponent); } } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { guard let downloadTask = task as? URLSessionDownloadTask, let item = dispatcher.sync(execute: { return self.inProgress.removeValue(forKey: downloadTask); }) else { return; } item.completed(withError: error); } func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { guard let sizeLimit = dispatcher.sync(execute: { return self.inProgress[downloadTask]?.maxSize; }) else { return; } if (sizeLimit != NSURLSessionTransferSizeUnknown) { if ((totalBytesExpectedToWrite != NSURLSessionTransferSizeUnknown && totalBytesExpectedToWrite > sizeLimit + 32) || sizeLimit + 32 < totalBytesWritten) { downloadTask.cancel(); } } } } ================================================ FILE: SiskinIM/util/DownloadStore.swift ================================================ // // DownloadStore.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import UIKit import Martin class DownloadStore { static let instance = DownloadStore(); fileprivate let queue = DispatchQueue(label: "download_store_queue"); let diskCacheUrl: URL; //let cache = NSCache(); var size: Int { guard let enumerator: FileManager.DirectoryEnumerator = FileManager.default.enumerator(at: diskCacheUrl, includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .isRegularFileKey]) else { return 0; } return enumerator.map({ $0 as! URL}).filter({ (try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile ?? false}).map({ (try? $0.resourceValues(forKeys: [.totalFileAllocatedSizeKey]))?.totalFileAllocatedSize ?? 0}).reduce(0, +); // for enu // return (.map { url -> Int in // return (try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize ?? 0) ?? 0; // }.reduce(0, +)) ?? 0; } init() { diskCacheUrl = try! FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("download", isDirectory: true); if !FileManager.default.fileExists(atPath: diskCacheUrl.path) { try! FileManager.default.createDirectory(at: diskCacheUrl, withIntermediateDirectories: true, attributes: nil); } NotificationCenter.default.addObserver(self, selector: #selector(messageRemoved(_:)), name: DBChatHistoryStore.MESSAGE_REMOVED, object: nil); } @objc func messageRemoved(_ notification: Notification) { guard let item = notification.object as? ConversationEntry else { return; } self.deleteFile(for: "\(item.id)") } func clear(olderThan: Date? = nil) { guard let messageDirs = try? FileManager.default.contentsOfDirectory(at: diskCacheUrl, includingPropertiesForKeys: [.creationDateKey], options: .skipsSubdirectoryDescendants) else { return; } for dir in messageDirs { if olderThan == nil || (((try? dir.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date()) < olderThan!), let id = Int(dir.lastPathComponent) { do { try FileManager.default.removeItem(at: dir); DBChatHistoryStore.instance.updateItem(id: id, updateAppendix: { appendix in appendix.state = .removed; }); } catch {} } } } func store(_ source: URL, filename: String, with id: String) -> URL { let fileDir = diskCacheUrl.appendingPathComponent(id, isDirectory: true); if !FileManager.default.fileExists(atPath: fileDir.path) { try! FileManager.default.createDirectory(at: fileDir, withIntermediateDirectories: true, attributes: nil); } try? FileManager.default.copyItem(at: source, to: fileDir.appendingPathComponent(filename)); if !FileManager.default.fileExists(atPath: fileDir.appendingPathComponent(id).path) { try! FileManager.default.createSymbolicLink(at: fileDir.appendingPathComponent(id), withDestinationURL: fileDir.appendingPathComponent(filename)); } return fileDir.appendingPathComponent(filename); } func url(for id: String) -> URL? { let linkUrl = diskCacheUrl.appendingPathComponent(id, isDirectory: true).appendingPathComponent(id); guard let filePath = try? FileManager.default.destinationOfSymbolicLink(atPath: linkUrl.path) else { return nil; } let filename = URL(fileURLWithPath: filePath).lastPathComponent; return diskCacheUrl.appendingPathComponent(id, isDirectory: true).appendingPathComponent(filename); } func deleteFile(for id: String) { let fileDir = diskCacheUrl.appendingPathComponent(id, isDirectory: true); guard FileManager.default.fileExists(atPath: fileDir.path) else { return; } try? FileManager.default.removeItem(at: fileDir); MetadataCache.instance.removeMetadata(for: id); } } ================================================ FILE: SiskinIM/util/InvitationsManager.swift ================================================ // // InvitationsManager.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Combine import Martin import UserNotifications class InvitationManager { static let instance = InvitationManager(); func addPresenceSubscribe(for account: BareJID, from jid: JID) { let senderName = DBRosterStore.instance.item(for: account, jid: jid.withoutResource)?.name ?? jid.stringValue; let content = UNMutableNotificationContent(); content.body = String.localizedStringWithFormat(NSLocalizedString("Received presence subscription request from %@", comment: "presence subscription request notification"), senderName); content.userInfo = ["sender": jid.stringValue as NSString, "account": account.stringValue as NSString, "senderName": senderName as NSString]; content.categoryIdentifier = "SUBSCRIPTION_REQUEST"; content.threadIdentifier = "account=\(account.stringValue)|sender=\(jid.stringValue)"; UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)); } func addMucInvitation(for account: BareJID, roomJid: BareJID, invitation: MucModule.Invitation) { let content = UNMutableNotificationContent(); content.body = String.localizedStringWithFormat(NSLocalizedString("Invitation to groupchat %@", comment: "muc invitation notification"), roomJid.stringValue); if let from = invitation.inviter, let name = DBRosterStore.instance.item(for: account, jid: from.withoutResource)?.name { content.body = "\(content.body) from \(name)"; } content.threadIdentifier = "mucRoomInvitation=\(account.stringValue)|room=\(roomJid.stringValue)"; content.categoryIdentifier = "MUC_ROOM_INVITATION"; content.userInfo = ["account": account.stringValue, "roomJid": roomJid.stringValue, "password": invitation.password as Any]; UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil), withCompletionHandler: nil); } func rejectPresenceSubscription(for account: BareJID, from jid: JID) { let threadId = "account=\(account.stringValue)|sender=\(jid.stringValue)"; UNUserNotificationCenter.current().getDeliveredNotifications(completionHandler: { notifications in let subscriptionReqNotifications = notifications.filter({ $0.request.content.categoryIdentifier == "SUBSCRIPTION_REQUEST" && $0.request.content.threadIdentifier == threadId }); guard !subscriptionReqNotifications.isEmpty else { return; } UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: subscriptionReqNotifications.map({ $0.request.identifier })); XmppService.instance.getClient(for: account)?.module(.presence).unsubscribed(by: jid); }) } } ================================================ FILE: SiskinIM/util/MainNotificationManagerProvider.swift ================================================ // // MainNotificationManagerProvider.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Shared import Intents class MainNotificationManagerProvider: NotificationManagerProvider { func avatar(on account: BareJID, for sender: BareJID) -> INImage? { guard let data = AvatarManager.instance.avatar(for: sender, on: account)?.jpegData(compressionQuality: 0.7) else { return nil; } return INImage(imageData: data); } func conversationNotificationDetails(for account: BareJID, with jid: BareJID, completionHandler: @escaping (ConversationNotificationDetails)->Void) { if let item = DBChatStore.instance.conversation(for: account, with: jid) { switch item { case let room as Room: completionHandler(ConversationNotificationDetails(name: room.displayName, notifications: item.notifications, type: .room, nick: room.nickname)); return; case let channel as Channel: completionHandler(ConversationNotificationDetails(name: channel.displayName, notifications: channel.notifications, type: .channel, nick: channel.nickname)); return; case let chat as Chat: completionHandler(ConversationNotificationDetails(name: chat.displayName, notifications: chat.notifications, type: .chat, nick: nil)); return; default: break; } } completionHandler(ConversationNotificationDetails(name: DBRosterStore.instance.item(for: account, jid: JID(jid))?.name ?? jid.stringValue, notifications: .always, type: .chat, nick: nil)); } func countBadge(withThreadId: String?, completionHandler: @escaping (Int) -> Void) { NotificationsManagerHelper.unreadChatsThreadIds() { result in var unreadChats = result; DBChatStore.instance.conversations.filter({ chat -> Bool in return chat.unread > 0; }).forEach { (chat) in unreadChats.insert("account=" + chat.account.stringValue + "|sender=" + chat.jid.stringValue) } if let threadId = withThreadId { unreadChats.insert(threadId); } completionHandler(unreadChats.count); } } func shouldShowNotification(account: BareJID, sender jid: BareJID?, body msg: String?, completionHandler: @escaping (Bool)->Void) { guard let sender = jid, let body = msg else { completionHandler(true); return; } if let conv = DBChatStore.instance.conversation(for: account, with: sender) { switch conv { case let room as Room: switch room.options.notifications { case .none: completionHandler(false); case .always: completionHandler(true); case .mention: completionHandler(body.contains(room.nickname)); } case let chat as Chat: switch chat.options.notifications { case .none: completionHandler(false); default: if Settings.notificationsFromUnknown { completionHandler(true); } else { let known = DBRosterStore.instance.item(for: account, jid: JID(sender)) != nil; completionHandler(known) } } case let channel as Channel: switch channel.options.notifications { case .none: completionHandler(false); case .always: completionHandler(true); case .mention: if let nickname = channel.nickname { completionHandler(body.contains(nickname)); } else { completionHandler(false); } } default: completionHandler(true); } } else { completionHandler(false); } } } ================================================ FILE: SiskinIM/util/MediaHelper.swift ================================================ // // MediaHelper.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import AVKit import Shared extension MediaHelper { static func askImageQuality(controller: UIViewController, forceQualityQuestion askQuality: Bool, _ completionHandler: @escaping (Result)->Void) { if let quality = askQuality ? nil : Settings.imageQuality { completionHandler(.success(quality)); } else { DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Select quality", comment: "media quality selection instruction"), message: nil, preferredStyle: .alert); let values: [ImageQuality] = [.original, .highest, .high, .medium, .low]; for value in values { alert.addAction(UIAlertAction(title: value.rawValue.capitalized, style: .default, handler: { _ in completionHandler(.success(value)); })); } alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: { _ in completionHandler(.failure(.noAccessError)); })) controller.present(alert, animated: true); } } } static func askVideoQuality(controller: UIViewController, forceQualityQuestion askQuality: Bool, _ completionHandler: @escaping (Result)->Void) { if let quality = askQuality ? nil : Settings.videoQuality { completionHandler(.success(quality)); } else { DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Select quality", comment: "media quality selection instruction"), message: nil, preferredStyle: .alert); let values: [VideoQuality] = [.original, .high, .medium, .low]; for value in values { alert.addAction(UIAlertAction(title: value.rawValue.capitalized, style: .default, handler: { _ in completionHandler(.success(value)); })); } alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: { _ in completionHandler(.failure(.noAccessError)); })) controller.present(alert, animated: true); } } } } ================================================ FILE: SiskinIM/util/MessageEncryption.swift ================================================ // // MessageEncryption.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation public enum MessageEncryption: Int { case none = 0 case decrypted = 1 case decryptionFailed = 2 case notForThisDevice = 3 } ================================================ FILE: SiskinIM/util/MetadataCache.swift ================================================ // // MetadataCache.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import LinkPresentation import Martin class MetadataCache { static let instance = MetadataCache(); private var cache: [URL: Result] = [:]; private let diskCacheUrl: URL; private let dispatcher = QueueDispatcher(label: "MetadataCache"); private var inProgress: [URL: OperationQueue] = [:]; var size: Int { guard let enumerator: FileManager.DirectoryEnumerator = FileManager.default.enumerator(at: diskCacheUrl, includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .isRegularFileKey]) else { return 0; } return enumerator.map({ $0 as! URL}).filter({ (try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile ?? false}).map({ (try? $0.resourceValues(forKeys: [.totalFileAllocatedSizeKey]))?.totalFileAllocatedSize ?? 0}).reduce(0, +); } init() { diskCacheUrl = try! FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("metadata", isDirectory: true); if !FileManager.default.fileExists(atPath: diskCacheUrl.path) { try! FileManager.default.createDirectory(at: diskCacheUrl, withIntermediateDirectories: true, attributes: nil); } NotificationCenter.default.addObserver(self, selector: #selector(messageRemoved), name: DBChatHistoryStore.MESSAGE_REMOVED, object: nil); } @objc func messageRemoved(_ notification: Notification) { guard let item = notification.object as? ConversationEntry, case .deleted = item.payload else { return; } removeMetadata(for: "\(item.id)"); } func store(_ value: LPLinkMetadata, for id: String) { let fileUrl = diskCacheUrl.appendingPathComponent("\(id).metadata"); guard let codedData = try? NSKeyedArchiver.archivedData(withRootObject: value, requiringSecureCoding: false) else { return; } try? codedData.write(to: fileUrl); } func metadata(for id: String) -> LPLinkMetadata? { guard let data = FileManager.default.contents(atPath: diskCacheUrl.appendingPathComponent("\(id).metadata").path) else { return nil; } return try? NSKeyedUnarchiver.unarchivedObject(ofClass: LPLinkMetadata.self, from: data); } func removeMetadata(for id: String) { try? FileManager.default.removeItem(at: diskCacheUrl.appendingPathComponent("\(id).metadata")); } func clear(olderThan: Date? = nil) { guard let messageDirs = try? FileManager.default.contentsOfDirectory(at: diskCacheUrl, includingPropertiesForKeys: [.creationDateKey], options: .skipsSubdirectoryDescendants) else { return; } for dir in messageDirs { if olderThan == nil || (((try? dir.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date()) < olderThan!) { do { try FileManager.default.removeItem(at: dir); } catch {} } } } func generateMetadata(for url: URL, withId id: String, completionHandler: @escaping (LPLinkMetadata?)->Void) { dispatcher.async { if let queue = self.inProgress[url] { queue.addOperation { completionHandler(self.metadata(for: id)); } } else { let queue = OperationQueue(); queue.isSuspended = true; self.inProgress[url] = queue; queue.addOperation { completionHandler(self.metadata(for: id)); } DispatchQueue.main.async { let provider = LPMetadataProvider(); provider.startFetchingMetadata(for: url, completionHandler: { (meta, error) in if let metadata = meta { self.store(metadata, for: id); } else { let metadata = LPLinkMetadata(); metadata.originalURL = url; self.store(metadata, for: id); } self.dispatcher.async { self.inProgress.removeValue(forKey: url); queue.isSuspended = false; } }) } } } } enum CacheError: Error { case NO_DATA case RETRIEVAL_ERROR } } ================================================ FILE: SiskinIM/util/OSLog.swift ================================================ // // OSLog.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import os extension OSLog { private static var subsystem = Bundle.main.bundleIdentifier!; static let chatStore = OSLog(subsystem: subsystem, category: "ChatStore"); static let chatHistorySync = OSLog(subsystem: subsystem, category: "mam-sync"); static let jingle = OSLog(subsystem: subsystem, category: "jingle"); static let avatar = OSLog(subsystem: subsystem, category: "avatar"); } ================================================ FILE: SiskinIM/util/OpenSSL_AES_GCM_Engine.swift ================================================ // // OpenSSL_AES_GCM_Engine.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import MartinOMEMO import Shared class OpenSSL_AES_GCM_Engine: Cipher.AES_GCM, AES_GCM_Engine { override init() { super.init(); } } ================================================ FILE: SiskinIM/util/PresenceStore.swift ================================================ // // PresenceStore.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import Combine class PresenceStore: Martin.PresenceStore { public static let instance = PresenceStore.init(); struct Key: Hashable { let account: BareJID; let jid: BareJID; } typealias PresenceHolder = Martin.DefaultPresenceStore.PresenceHolder; private let dispatcher = QueueDispatcher(label: "presence_store_queue", attributes: DispatchQueue.Attributes.concurrent); @Published public private(set) var bestPresences: [Key: Presence] = [:]; public var bestPresencesPublisher: Published<[Key: Presence]>.Publisher { return $bestPresences; } public let bestPresenceEvents = PassthroughSubject(); private var presencesByBareJID: [Key: PresenceHolder] = [:]; public init() { } public func reset(scopes: Set, for context: Context) { if scopes.contains(.session) { dispatcher.sync(flags: .barrier) { let keysToRemove = Set(presencesByBareJID.keys.filter({ $0.account == context.userBareJid })); presencesByBareJID = presencesByBareJID.filter({ !keysToRemove.contains($0.key) }); let events = keysToRemove.map({ BestPresenceEvent(account: $0.account, jid: $0.jid, presence: nil)}); bestPresences = bestPresences.filter({ !keysToRemove.contains($0.key) }); for event in events { bestPresenceEvents.send(event); } } } } open func isAvailable(for jid: BareJID, context: Context) -> Bool { return bestPresence(for: jid, context: context)?.show != nil; } open func presence(for jid: JID, context: Context) -> Presence? { return dispatcher.sync { return self.presencesByBareJID[.init(account: context.userBareJid, jid: jid.bareJid)]?.presence(for: jid); } } open func presences(for jid: BareJID, context: Context) -> [Presence] { return dispatcher.sync { return self.presencesByBareJID[.init(account: context.userBareJid, jid: jid)]?.presences; } ?? []; } public func bestPresence(for jid: BareJID, context: Context) -> Presence? { return bestPresences[.init(account: context.userBareJid, jid: jid)]; } public func bestPresence(for jid: BareJID, on account: BareJID) -> Presence? { return bestPresences[.init(account: account, jid: jid)]; } open func update(presence: Presence, for context: Context) -> Presence? { guard let jid = presence.from else { return nil; } let key = Key(account: context.userBareJid, jid: jid.bareJid); return dispatcher.sync(flags: .barrier) { let holder = self.holder(for: key); holder.update(presence: presence); if let best = holder.bestPresence, self.bestPresences[key] !== best { self.bestPresences[key] = best; self.bestPresenceEvents.send(.init(account: context.userBareJid, jid: jid.bareJid, presence: best)); } return nil; } } open func removePresence(for jid: JID, context: Context) -> Bool { let key = Key(account: context.userBareJid, jid: jid.bareJid); return dispatcher.sync(flags: .barrier) { guard let holder = self.presencesByBareJID[key] else { return false; } holder.remove(for: jid); if let best = holder.bestPresence { self.bestPresences[key] = best; self.bestPresenceEvents.send(.init(account: context.userBareJid, jid: jid.bareJid, presence: best)); return false; } else { self.presencesByBareJID.removeValue(forKey: key); self.bestPresences.removeValue(forKey: key); self.bestPresenceEvents.send(.init(account: context.userBareJid, jid: jid.bareJid, presence: nil)); return true; } } } private func holder(for key: Key) -> PresenceHolder { guard let holder = self.presencesByBareJID[key] else { let holder = PresenceHolder(); self.presencesByBareJID[key] = holder; return holder; } return holder; } } ================================================ FILE: SiskinIM/util/ServerCertificateInfo.swift ================================================ // // ServerCertificateInfo.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin open class ServerCertificateInfo: SslCertificateInfo { public var accepted: Bool; public override init(trust: SecTrust) { self.accepted = false; super.init(trust: trust); } public init(sslCertificateInfo: SslCertificateInfo, accepted: Bool) { self.accepted = accepted; super.init(sslCertificateInfo: sslCertificateInfo); } public required init?(coder aDecoder: NSCoder) { accepted = aDecoder.decodeBool(forKey: "accepted"); super.init(coder: aDecoder); } public override func encode(with aCoder: NSCoder) { aCoder.encode(accepted, forKey: "accepted"); super.encode(with: aCoder); } } ================================================ FILE: SiskinIM/util/Settings.swift ================================================ // // Settings.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin import Combine import Shared @propertyWrapper class UserDefaultsSetting { let key: String; var storage: UserDefaults = .standard; var value: CurrentValueSubject; var projectedValue: AnyPublisher { get { return value.eraseToAnyPublisher(); } set { // nothing to do.. } } var wrappedValue: Value { get { return value.value; } set { storage.setValue(newValue, forKey: key); self.value.value = newValue; } } init(key: String, defaultValue: Value, storage: UserDefaults = .standard) { self.key = key; self.storage = storage; let value: Value = storage.value(forKey: key) as? Value ?? defaultValue; self.value = CurrentValueSubject(value); } } @propertyWrapper class UserDefaultsRawSetting { let key: String; var storage: UserDefaults = .standard; var value: CurrentValueSubject; var projectedValue: AnyPublisher { get { return value.eraseToAnyPublisher(); } set { // nothing to do.. } } var wrappedValue: Value { get { return value.value; } set { storage.setValue(newValue, forKey: key); self.value.value = newValue; } } init(key: String, defaultValue: Value, storage: UserDefaults = .standard) { self.key = key; self.storage = storage; let value: Value = storage.value(forKey: key) ?? defaultValue; self.value = CurrentValueSubject(value); } } extension UserDefaultsSetting where Value: ExpressibleByNilLiteral { convenience init(key: String, storage: UserDefaults = .standard) { self.init(key: key, defaultValue: nil, storage: storage); } } extension UserDefaults { func value(forKey key: String) -> T? { guard let value = value(forKey: key) as? T.RawValue else { return nil; } return T(rawValue: value); } func setValue(_ value: T?, forKey key: String) { set(value?.rawValue, forKey: key); } } @propertyWrapper class UserDefaultsOptionalRawSetting { let key: String; var storage: UserDefaults = .standard; var value: CurrentValueSubject; var projectedValue: AnyPublisher { get { return value.eraseToAnyPublisher(); } set { // nothing to do.. } } var wrappedValue: Value? { get { return value.value; } set { storage.setValue(newValue, forKey: key); self.value.value = newValue; } } init(key: String, defaultValue: Value?, storage: UserDefaults = .standard) { self.key = key; self.storage = storage; let value: Value? = storage.value(forKey: key) ?? defaultValue; self.value = CurrentValueSubject(value); } } class SettingsStore { @UserDefaultsSetting(key: "defaultAccount") var defaultAccount: String?; @UserDefaultsOptionalRawSetting(key: "StatusType", defaultValue: nil) var statusType: Presence.Show?; @UserDefaultsSetting(key: "StatusMessage") var statusMessage: String?; @UserDefaultsRawSetting(key: "RosterType", defaultValue: .flat) var rosterType: RosterType; @UserDefaultsRawSetting(key: "RosterItemsOrder", defaultValue: .alphabetical) var rosterItemsOrder: RosterSortingOrder; @UserDefaultsSetting(key: "RosterAvailableOnly", defaultValue: false) var rosterAvailableOnly: Bool; @UserDefaultsSetting(key: "RosterDisplayHiddenGroup", defaultValue: false) var rosterDisplayHiddenGroup: Bool; @UserDefaultsSetting(key: "AutoSubscribeOnAcceptedSubscriptionRequest", defaultValue: true) var autoSubscribeOnAcceptedSubscriptionRequest: Bool; @UserDefaultsSetting(key: "NotificationsFromUnknown", defaultValue: true) var notificationsFromUnknown: Bool; @UserDefaultsSetting(key: "RecentsMessageLinesNo", defaultValue: 2) var recentsMessageLinesNo: Int; @UserDefaultsSetting(key: "SharingViaHttpUpload", defaultValue: false) var sharingViaHttpUpload: Bool; @UserDefaultsSetting(key: "fileDownloadSizeLimit", defaultValue: 4) var fileDownloadSizeLimit: Int; @UserDefaultsSetting(key: "confirmMessages", defaultValue: true) var confirmMessages: Bool; @UserDefaultsSetting(key: "SendMessageOnReturn", defaultValue: true) var sendMessageOnReturn: Bool; @UserDefaultsSetting(key: "CopyMessagesWithTimestamps", defaultValue: false) var copyMessagesWithTimestamps: Bool; @UserDefaultsSetting(key: "XmppPipelining", defaultValue: false) var xmppPipelining: Bool; @UserDefaultsSetting(key: "enableBookmarksSync", defaultValue: true) var enableBookmarksSync: Bool; @UserDefaultsRawSetting(key: "messageEncryption", defaultValue: ConversationEncryption.none) var messageEncryption: ConversationEncryption; @UserDefaultsSetting(key: "markdown", defaultValue: true) var enableMarkdownFormatting: Bool; @UserDefaultsSetting(key: "ShowEmoticons", defaultValue: false) var showEmoticons: Bool; @UserDefaultsSetting(key: "linkPreviews", defaultValue: true) var linkPreviews: Bool; @UserDefaultsRawSetting(key: "appearance", defaultValue: .auto) var appearance: Appearance @UserDefaultsSetting(key: "usePublicStunServers", defaultValue: true) var usePublicStunServers: Bool; @UserDefaultsRawSetting(key: "imageQuality", defaultValue: .medium) var imageQuality: ImageQuality @UserDefaultsRawSetting(key: "videoQuality", defaultValue: .medium) var videoQuality: VideoQuality @UserDefaultsSetting(key: "enablePush", defaultValue: nil) var enablePush: Bool?; public static let sharedDefaults = UserDefaults(suiteName: "group.TigaseMessenger.Share")!; private var cancellables: Set = []; fileprivate init() { $sharingViaHttpUpload.sink(receiveValue: { value in SettingsStore.sharedDefaults.setValue(value, forKey: "SharingViaHttpUpload"); }).store(in: &cancellables); $imageQuality.sink(receiveValue: { value in SettingsStore.sharedDefaults.setValue(value.rawValue, forKey: "imageQuality"); }).store(in: &cancellables); $videoQuality.sink(receiveValue: { value in SettingsStore.sharedDefaults.setValue(value.rawValue, forKey: "videoQuality"); }).store(in: &cancellables); } public static func initialize() { UserDefaults.standard.removeObject(forKey: "DeleteChatHistoryOnClose"); UserDefaults.standard.removeObject(forKey: "enableMessageCarbons"); UserDefaults.standard.removeObject(forKey: "DeviceToken"); UserDefaults.standard.removeObject(forKey: "RecentsOrder"); UserDefaults.standard.removeObject(forKey: "AppearanceTheme"); if UserDefaults.standard.value(forKey: "confirmMessages") == nil { if let value = UserDefaults.standard.value(forKey: "MessageDeliveryReceiptsEnabled") as? Bool { UserDefaults.standard.setValue(value, forKey: "confirmMessages"); UserDefaults.standard.removeObject(forKey: "MessageDeliveryReceiptsEnabled"); } } DispatchQueue.global(qos: .background).async { let removeOlder = Date().addingTimeInterval(7 * 24 * 60 * 60 * (-1.0)); for (k,v) in SettingsStore.sharedDefaults.dictionaryRepresentation() { if k.starts(with: "upload-") { let hash = k.replacingOccurrences(of: "upload-", with: ""); if let timestamp = (v as? [String: Any])?["timestamp"] as? Date { if timestamp < removeOlder { SettingsStore.sharedDefaults.removeObject(forKey: k); let localUploadDirUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.siskinim.shared")!.appendingPathComponent("upload", isDirectory: true).appendingPathComponent(hash, isDirectory: false); if FileManager.default.fileExists(atPath: localUploadDirUrl.path) { try? FileManager.default.removeItem(at: localUploadDirUrl); } } } else { SettingsStore.sharedDefaults.removeObject(forKey: k); let localUploadDirUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.siskinim.shared")!.appendingPathComponent("upload", isDirectory: true).appendingPathComponent(hash, isDirectory: false); if FileManager.default.fileExists(atPath: localUploadDirUrl.path) { try? FileManager.default.removeItem(at: localUploadDirUrl); } } } } } let suffixesToRemove = ["MessageSyncAutomatic", "MessageSyncPeriod", "MessageSyncTime"]; let keysToRemove = UserDefaults.standard.dictionaryRepresentation().keys.filter({ key in for suffix in suffixesToRemove { if suffix.hasSuffix(".\(suffix)") { return true; } } return false; }); for key in keysToRemove { UserDefaults.standard.removeObject(forKey: key); } } } let Settings: SettingsStore = { SettingsStore.initialize(); return SettingsStore(); }(); enum Appearance: String, CustomStringConvertible { case auto case light case dark var description: String { switch self { case .auto: return NSLocalizedString("Auto", comment: "appearance type") case .light: return NSLocalizedString("Light", comment: "appearance type") case .dark: return NSLocalizedString("Dark", comment: "appearance type") } } var value: UIUserInterfaceStyle { switch self { case .auto: return .unspecified; case .light: return .light; case .dark: return .dark; } } } public struct AccountSettingsStore { enum Key: String { case PushNotificationsForAway case LastError case knownServerFeatures = "KnownServerFeatures" case omemoRegistrationId case reconnectionLocation case pushHash } var storage: UserDefaults = .standard; func pushNotificationsForAway(for account: BareJID) -> Bool { return value(for: account, key: .PushNotificationsForAway) ?? false; } func pushNotificationsForAway(for account: BareJID, value: Bool) { self.value(for: account, key: .PushNotificationsForAway, value: value); } func lastError(for account: BareJID) -> String? { return value(for: account, key: .LastError); } func lastError(for account: BareJID, value: String?) { self.value(for: account, key: .LastError, value: value); } func knownServerFeatures(for account: BareJID) -> [ServerFeature] { guard let features: [String] = value(for: account, key: .knownServerFeatures) else { return []; } let serverFeatures = features.compactMap({ ServerFeature(rawValue: $0) }); if serverFeatures.count == 0 && !features.isEmpty { // if this does not match, we may have features in old format.. var updateFeatures = ServerFeature.from(features: features); updateFeatures.removeAll(where: { $0 == .push }); knownServerFeatures(for: account, value: updateFeatures); return updateFeatures; } return serverFeatures; } func knownServerFeatures(for account: BareJID, value: [ServerFeature]) { self.value(for: account, key: .knownServerFeatures, value: value.map({ $0.rawValue })); } func omemoRegistrationId(for account: BareJID) -> UInt32? { guard let value: String = self.value(for: account, key: .omemoRegistrationId) else { return nil; } return UInt32(value); } func omemoRegistrationId(for account: BareJID, value: UInt32?) { self.value(for: account, key: .omemoRegistrationId, value: value == nil ? nil : String(value!)); } func reconnectionLocation(for account: BareJID) -> ConnectorEndpoint? { guard let string: String = value(for: account, key: .reconnectionLocation) else { return nil; } return try? JSONDecoder().decode(SocketConnectorNetwork.Endpoint.self, from: Data(base64Encoded: string)!); } func reconnectionLocation(for account: BareJID, value: ConnectorEndpoint?) { let endpoint = value as? SocketConnectorNetwork.Endpoint; let data = try? JSONEncoder().encode(endpoint); self.value(for: account, key: .reconnectionLocation, value: data?.base64EncodedString()); } func pushHash(for account: BareJID) -> Int { return value(for: account, key: .pushHash) ?? 0; } func pushHash(for account: BareJID, value: Int) { self.value(for: account, key: .pushHash, value: value); } func value(for account: BareJID, key: Key) -> V? { return storage.value(forKey: self.key(for: account, key: key)) as? V; } func value(for account: BareJID, key: Key, value: V?) { storage.setValue(value, forKey: self.key(for: account, key: key)); } private func key(for account: BareJID, key: Key) -> String { return "accounts.\(account).\(key.rawValue)"; } public func initialize() { let accounts = AccountManager.getAccounts(); let toRemove = storage.dictionaryRepresentation().keys.filter { (key) -> Bool in return key.hasPrefix("accounts.") && accounts.firstIndex(where: { (account) -> Bool in return key.hasPrefix("accounts.\(account.stringValue)."); }) == nil; }; toRemove.forEach { (key) in storage.removeObject(forKey: key); } } } let AccountSettings = AccountSettingsStore(); ================================================ FILE: SiskinIM/util/SiskinPushNotificationsModuleProvider.swift ================================================ // // SiskinPushNotificationsModuleProvider.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin class SiskinPushNotificationsModuleProvider: SiskinPushNotificationsModuleProviderProtocol { func mutedChats(for context: Context) -> [BareJID] { return DBChatStore.instance.chats(for: context).filter({ $0.options.notifications == .none }).map({ $0.jid }).sorted { (j1, j2) -> Bool in return j1.stringValue.compare(j2.stringValue) == .orderedAscending; } } func groupchatFilterRules(for context: Context) -> [TigasePushNotificationsModule.GroupchatFilter.Rule] { return DBChatStore.instance.conversations(for: context.userBareJid).filter({ (c) -> Bool in switch c { case let channel as Channel: switch channel.options.notifications { case .none: return false; case .always, .mention: return true; } case let room as Room: switch room.options.notifications { case .none: return false; case .always, .mention: return true; } default: break; } return false; }).sorted(by: { (r1, r2) -> Bool in return r1.jid.stringValue.compare(r2.jid.stringValue) == .orderedAscending; }).map({ (c) -> TigasePushNotificationsModule.GroupchatFilter.Rule in switch c { case let channel as Channel: switch channel.options.notifications { case .none: return .never(room: channel.channelJid); case .always: return .always(room: channel.channelJid); case .mention: return .mentioned(room: channel.channelJid, nickname: channel.nickname ?? ""); } case let room as Room: switch room.options.notifications { case .none: return .never(room: room.roomJid); case .always: return .always(room: room.roomJid); case .mention: return .mentioned(room: room.roomJid, nickname: room.nickname); } default: // should not happen return .never(room: c.account); } }); } } ================================================ FILE: SiskinIM/util/TasksQueue.swift ================================================ // // TasksQueue.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin public class KeyedTasksQueue { private let dispatcher = QueueDispatcher(label: "TasksQueue"); private var queues: [BareJID:[Task]] = [:]; private var inProgress: [BareJID] = []; func schedule(for key: BareJID, task: @escaping Task) { dispatcher.async { var queue = self.queues[key] ?? []; queue.append(task); self.queues[key] = queue; self.execute(for: key); } } private func execute(for key: BareJID) { dispatcher.async { guard !self.inProgress.contains(key) else { return; } if var queue = self.queues[key], !queue.isEmpty { self.inProgress.append(key); let task = queue.removeFirst(); if queue.isEmpty { self.queues.removeValue(forKey: key); } else { self.queues[key] = queue; } task({ self.executed(for: key); }) } } } private func executed(for key: BareJID) { dispatcher.async { self.inProgress = self.inProgress.filter({ (k) -> Bool in return k != key; }); self.execute(for: key); } } typealias Task = (@escaping ()->Void) -> Void; } ================================================ FILE: SiskinIM/util/UIColor_mix.swift ================================================ // // UIColor_mix.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit extension UIColor { func mix(color second: UIColor, ratio _ratio: CGFloat?) -> UIColor { var red1: CGFloat = 0; var green1: CGFloat = 0; var blue1: CGFloat = 0; var alpha1: CGFloat = 0; self.getRed(&red1, green: &green1, blue: &blue1, alpha: &alpha1); var red2: CGFloat = 0; var green2: CGFloat = 0; var blue2: CGFloat = 0; var alpha2: CGFloat = 0; second.getRed(&red2, green: &green2, blue: &blue2, alpha: &alpha2); let ratio = _ratio ?? alpha2; return UIColor(red: (1-ratio) * red1 + (ratio * red2), green: (1-ratio) * green1 + ratio * green2, blue: (1-ratio) * blue1 + ratio * blue2, alpha: 1.0); } func darker(ratio: CGFloat) -> UIColor { return adjust(darker: true, ratio: ratio); } func lighter(ratio: CGFloat) -> UIColor { return adjust(darker: false, ratio: ratio); } func adjust(darker: Bool, ratio: CGFloat) -> UIColor { var hue: CGFloat = 0; var saturation: CGFloat = 0; var brightness: CGFloat = 0; var alpha: CGFloat = 0; if !getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) { var red1: CGFloat = 0; var green1: CGFloat = 0; var blue1: CGFloat = 0; var alpha1: CGFloat = 0; self.getRed(&red1, green: &green1, blue: &blue1, alpha: &alpha1); let tmp = UIColor(red: red1, green: green1, blue: blue1, alpha: alpha1); if !tmp.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) { return self; } } let change = ratio;//brightness * ratio;//darker ? (brightness * ratio) : ((1-brightness) * ratio); if darker { return UIColor(hue: hue, saturation: saturation, brightness: max(brightness - change, 0.0), alpha: alpha); } else { return UIColor(hue: hue, saturation: saturation, brightness: min(brightness + change, 1.0), alpha: alpha); } } func adjust1(brightness: CGFloat) -> UIColor { var hue: CGFloat = 0; var saturation: CGFloat = 0; var oldBrightness: CGFloat = 0; var alpha: CGFloat = 0; if !getHue(&hue, saturation: &saturation, brightness: &oldBrightness, alpha: &alpha) { var red1: CGFloat = 0; var green1: CGFloat = 0; var blue1: CGFloat = 0; var alpha1: CGFloat = 0; self.getRed(&red1, green: &green1, blue: &blue1, alpha: &alpha1); let tmp = UIColor(red: red1, green: green1, blue: blue1, alpha: alpha1); if !tmp.getHue(&hue, saturation: &saturation, brightness: &oldBrightness, alpha: &alpha) { return self; } } return UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: alpha); } func adjust2(brightness: CGFloat) -> UIColor { var r: CGFloat = 0; var g: CGFloat = 0; var b: CGFloat = 0; var a: CGFloat = 0; self.getRed(&r, green: &g, blue: &b, alpha: &a); let minColor = min(r, g, b); let maxColor = max(r, g, b); let delta = maxColor - minColor; var hue: CGFloat = 0; if r == maxColor { hue = (g - b) / delta; } else if g == maxColor { hue = 2 + (b - r) / delta; } else { hue = 4 + (r - g) / delta; } hue = hue * 60; if hue < 0 { hue = hue + 360; } let saturation = maxColor == 0 ? 0 : (delta / maxColor); return UIColor(hue: hue/360, saturation: saturation, brightness: brightness, alpha: a); } func adjust(brightness: CGFloat) -> UIColor { var r: CGFloat = 0; var g: CGFloat = 0; var b: CGFloat = 0; var a: CGFloat = 0; self.getRed(&r, green: &g, blue: &b, alpha: &a); let minColor = min(r, g, b); let maxColor = max(r, g, b); var h: CGFloat = 0; var s: CGFloat = 0; var l = (maxColor + minColor) / 2; if minColor != maxColor { let d = maxColor - minColor; s = l > 0.5 ? (d / (2 - maxColor - minColor)) : (d / (maxColor + minColor)); switch maxColor { case r: h = (g - b) / d + (g < b ? 6 : 0); case g: h = (b - r) / d + 2; case b: h = (r - g) / d + 4; default: break; } h = h / 6; } l = brightness; if s == 0 { r = l; g = l; b = l; } else { let fn = { (p: CGFloat, q: CGFloat, t1: CGFloat) -> CGFloat in var t = t1; if (t < 0) { t = t + 1; } if (t > 1) { t = t - 1; } if (t < 1/6) { return p + (q - p) * 6 * t; } if (t < 1/2) { return q; } if (t < 2/3) { return p + (q - p) * (2/3 - t) * 6; } return p; }; let q = l < 0.5 ? (l * (1 + s)) : ((l+s) - (l*s)); let p = 2 * l - q; r = fn(p, q, h + 1/3); g = fn(p, q, h); b = fn(p, q, h - 1/3); } return UIColor(red: r, green: g, blue: b, alpha: a); } func toHex() -> String { guard let components = cgColor.converted(to: CGColorSpace(name: CGColorSpace.sRGB)!, intent: .perceptual, options: nil)?.components, components.count >= 3 else { return "nil"; } let r = lroundf(Float(components[0]) * 255); let g = lroundf(Float(components[1]) * 255); let b = lroundf(Float(components[2]) * 255); let a = components.count >= 4 ? lroundf(Float(components[3]) * 255) : 255; return String(format: "%02lX%02lX%02lX%02lX", r, g, b, a); } } ================================================ FILE: SiskinIM/util/VCardManager.swift ================================================ // // VCardManager.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin class VCardManager { public static let instance = VCardManager(); open func retrieveVCard(for jid: BareJID, on account: BareJID, completionHandler: @escaping (Result)->Void) { self.retrieveVCard(for: JID(jid), on: account, completionHandler: completionHandler); } open func retrieveVCard(for jid: JID, on account: BareJID, completionHandler: @escaping (Result)->Void) { guard let client = XmppService.instance.getClient(for: account) else { completionHandler(.failure(.undefined_condition)); return; } let queryJid = jid.bareJid == account ? nil : jid; self.retrieveVCard(module: client.module(.vcard4), for: queryJid, on: account) { (result) in switch result { case .success(let vcard): completionHandler(.success(vcard)); case .failure(_): self.retrieveVCard(module: client.module(.vcardTemp), for: queryJid, on: account, completionHandler: completionHandler); } } } open func refreshVCard(for jid: BareJID, on account: BareJID, completionHandler: ((Result)->Void)?) { retrieveVCard(for: jid, on: account, completionHandler: { result in switch result { case .success(let vcard): DBVCardStore.instance.updateVCard(for: jid, on: account, vcard: vcard); default: break; } completionHandler?(result); }) } fileprivate func retrieveVCard(module: VCardModuleProtocol, for jid: JID?, on account: BareJID, completionHandler: @escaping (Result)->Void) { module.retrieveVCard(from: jid, completionHandler: completionHandler); } open func fetchPhoto(photo: VCard.Photo, completionHandler: @escaping (Result)->Void) { if let binval = photo.binval { guard let data = Data(base64Encoded: binval, options: NSData.Base64DecodingOptions.ignoreUnknownCharacters) else { completionHandler(.failure(XMPPError.not_acceptable("Unable to decode base64 data"))); return; } completionHandler(.success(data)); } else if let uri = photo.uri { if uri.hasPrefix("data:image") && uri.contains(";base64,") { guard let idx = uri.firstIndex(of: ","), let data = Data(base64Encoded: String(uri.suffix(from: uri.index(after: idx))), options: NSData.Base64DecodingOptions.ignoreUnknownCharacters) else { completionHandler(.failure(XMPPError.not_acceptable("Unable to decode image URI"))); return; } completionHandler(.success(data)); } else if let url = URL(string: uri) { let task = URLSession.shared.dataTask(with: url) { (data, response, err) in if let error = err { completionHandler(.failure(error)); } else { completionHandler(.success(data!)); } }; task.resume(); } } else { completionHandler(.failure(XMPPError.item_not_found)); } } } ================================================ FILE: SiskinIM/util/combine/Publisher+OnlyGetter.swift ================================================ // // Publisher+OnlyGetter.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Combine extension Publisher where Self.Output : Comparable { func onlyGreater(than initialValue: Output? = nil) -> Publishers.Filter { var value: Output? = initialValue; return self.filter({ nextValue in if value == nil || (value! < nextValue) { value = nextValue; return true; } return false; }); } } ================================================ FILE: SiskinIM/util/combine/Publisher+ThrottleFixed.swift ================================================ // // Publisher+ThrottleFixed.swift // // Siskin IM // Copyright (C) 2022 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Combine extension Publisher where Failure == Never { func throttleFixed(for interval: TimeInterval, scheduler: S, latest: Bool) -> AnyPublisher where S: DispatchQueue { if #available(iOS 13.2, macOS 10.15, *) { return self.throttle(for: S.SchedulerTimeType.Stride.init(floatLiteral: interval), scheduler: scheduler, latest: latest).eraseToAnyPublisher(); } else { return self.throttle(for: RunLoop.SchedulerTimeType.Stride.init(floatLiteral: interval), scheduler: RunLoop.main, latest: latest).eraseToAnyPublisher(); } } } ================================================ FILE: SiskinIM/util/combine/Publisher+ThrottledSink.swift ================================================ // // Publisher+ThrottledSink.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Combine extension Publisher where Failure == Never { func throttledSink(for interval: S.SchedulerTimeType.Stride, scheduler: S, receiveValue: @escaping (Output)->Void) where S : Scheduler { var cancellable: AnyCancellable? = nil; cancellable = self.throttle(for: interval, scheduler: scheduler, latest: true).sink(receiveCompletion: { result in cancellable?.cancel(); cancellable = nil; }, receiveValue: receiveValue); } } ================================================ FILE: SiskinIM/vcard/VCardAvatarEditCell.swift ================================================ // // VCardAvatarEditCell.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class VCardAvatarEditCell: UITableViewCell { @IBOutlet var avatarView: AvatarView!; @IBOutlet var changeBtn: UIButton!; override func layoutSubviews() { self.changeBtn.isUserInteractionEnabled = false; super.layoutSubviews(); updateCornerRadius(); } func updateCornerRadius() { self.avatarView.layer.masksToBounds = true; self.avatarView.layer.cornerRadius = 100; } } ================================================ FILE: SiskinIM/vcard/VCardEditAddressTableViewCell.swift ================================================ // // VCardEditAddressTableViewCell.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class VCardEditAddressTableViewCell: VCardEntryTypeAwareTableViewCell, UITextFieldDelegate { var address: VCard.Address! { didSet { typeView.text = vcardEntryTypeName(for: address.types.first); streetView.text = address.street; postalCodeView.text = address.postalCode; cityView.text = address.locality; countryView.text = address.country; } } @IBOutlet var streetView: UITextField! @IBOutlet var postalCodeView: UITextField! @IBOutlet var cityView: UITextField! @IBOutlet var countryView: UITextField! override func awakeFromNib() { super.awakeFromNib() // Initialization code streetView.delegate = self; postalCodeView.delegate = self; countryView.delegate = self; cityView.delegate = self; } override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) // Configure the view for the selected state } override func typeSelected(_ type: VCard.EntryType) { address.types = [type]; } func textFieldDidEndEditing(_ textField: UITextField) { switch textField { case streetView: address.street = textField.text; case postalCodeView: address.postalCode = textField.text; case cityView: address.locality = textField.text; case countryView: address.country = textField.text; default: break; } } } ================================================ FILE: SiskinIM/vcard/VCardEditEmailTableViewCell.swift ================================================ // // VCardEditEmailTableViewCell.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class VCardEditEmailTableViewCell: VCardEntryTypeAwareTableViewCell, UITextFieldDelegate{ @IBOutlet var emailView: UITextField! var email: VCard.Email! { didSet { typeView.text = self.vcardEntryTypeName(for: email.types.first); emailView.text = email.address; } } override func awakeFromNib() { super.awakeFromNib() // Initialization code let typePicker = UIPickerView(); typePicker.dataSource = self; typePicker.delegate = self; typeView.inputView = typePicker; emailView.delegate = self; } override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) // Configure the view for the selected state } override func typeSelected(_ type: VCard.EntryType) { email.types = [type]; } func textFieldDidEndEditing(_ textField: UITextField) { email.address = textField.text; } } ================================================ FILE: SiskinIM/vcard/VCardEditPhoneTableViewCell.swift ================================================ // // VCardEditPhoneTableViewCell.swift // // Siskin IM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class VCardEditPhoneTableViewCell: VCardEntryTypeAwareTableViewCell, UITextFieldDelegate { @IBOutlet var phoneView: UITextField! var phone: VCard.Telephone! { didSet { phoneView.text = phone.number; typeView.text = vcardEntryTypeName(for: phone.types.first); } } override func awakeFromNib() { super.awakeFromNib() // Initialization code let typePicker = UIPickerView(); typePicker.dataSource = self; typePicker.delegate = self; typeView.inputView = typePicker; phoneView.delegate = self; } override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) // Configure the view for the selected state } override func typeSelected(_ type: VCard.EntryType) { phone.types = [type]; } func textFieldDidEndEditing(_ textField: UITextField) { phone.number = textField.text; } } ================================================ FILE: SiskinIM/vcard/VCardEditViewController.swift ================================================ // // VCardEditViewController.swift // // Siskin IMM // Copyright (C) 2016 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class VCardEditViewController: UITableViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate, UITextFieldDelegate { let picker = UIImagePickerController(); var client: XMPPClient!; var vcard: VCard? { didSet { tableView.reloadData(); } } var datePicker: UIDatePicker!; override func viewDidLoad() { super.viewDidLoad() tableView.isEditing = true; datePicker = UIDatePicker(); datePicker.datePickerMode = .date; datePicker.addTarget(self, action: #selector(VCardEditViewController.bdayValueChanged), for: .valueChanged); } override func viewWillAppear(_ animated: Bool) { DBVCardStore.instance.vcard(for: client.userBareJid, completionHandler: { vcard in DispatchQueue.main.async { self.vcard = vcard; } }) } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } /* // MARK: - Navigation // In a storyboard-based application, you will often want to do a little preparation before navigation override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { // Get the new view controller using segue.destinationViewController. // Pass the selected object to the new view controller. } */ override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch VCardSections(rawValue: indexPath.section)! { case .basic: switch VCardBaseSectionRows(rawValue: indexPath.row)! { case .avatar: let cell = tableView.dequeueReusableCell(withIdentifier: "AvatarEditCell") as! VCardAvatarEditCell; cell.avatarView.set(name: nil, avatar: nil); if let photo = vcard?.photos.first { VCardManager.instance.fetchPhoto(photo: photo, completionHandler: { result in switch result { case .success(let data): DispatchQueue.main.async { cell.avatarView.set(name: nil, avatar: UIImage(data: data)); } case .failure(_): break; } }); } cell.updateCornerRadius(); return cell; case .givenName: let cell = tableView.dequeueReusableCell(withIdentifier: "TextEditCell") as! VCardTextEditCell; cell.textField.placeholder = NSLocalizedString("Given name", comment: "vcard field label") cell.textField.text = vcard?.givenName; cell.textField.delegate = self; cell.textField.tag = indexPath.row; return cell; case .familyName: let cell = tableView.dequeueReusableCell(withIdentifier: "TextEditCell") as! VCardTextEditCell; cell.textField.placeholder = NSLocalizedString("Family name", comment: "vcard field label") cell.textField.text = vcard?.surname; cell.textField.delegate = self; cell.textField.tag = indexPath.row; return cell; case .fullName: let cell = tableView.dequeueReusableCell(withIdentifier: "TextEditCell") as! VCardTextEditCell; cell.textField.placeholder = NSLocalizedString("Full name", comment: "vcard field label") cell.textField.text = vcard?.fn; cell.textField.delegate = self; cell.textField.tag = indexPath.row; return cell; case .birthday: let cell = tableView.dequeueReusableCell(withIdentifier: "TextEditCell") as! VCardTextEditCell; cell.textField.placeholder = NSLocalizedString("Birthday", comment: "vcard field label") cell.textField.text = vcard?.bday; cell.textField.inputView = self.datePicker; cell.textField.tag = indexPath.row; return cell; case .organization: let cell = tableView.dequeueReusableCell(withIdentifier: "TextEditCell") as! VCardTextEditCell; cell.textField.placeholder = NSLocalizedString("Organization", comment: "vcard field label") cell.textField.text = vcard?.organizations.first?.name; cell.textField.delegate = self; cell.textField.tag = indexPath.row; return cell; case .organizationRole: let cell = tableView.dequeueReusableCell(withIdentifier: "TextEditCell") as! VCardTextEditCell; cell.textField.placeholder = NSLocalizedString("Organization role", comment: "vcard field label") cell.textField.text = vcard?.role; cell.textField.delegate = self; cell.textField.tag = indexPath.row; return cell; } case .phones: if indexPath.row < vcard?.telephones.count ?? 0 { let cell = tableView.dequeueReusableCell(withIdentifier: "PhoneEditCell") as! VCardEditPhoneTableViewCell; cell.phone = vcard?.telephones[indexPath.row]; return cell; } else { let cell = tableView.dequeueReusableCell(withIdentifier: "PhoneAddCell"); for subview in cell!.subviews { for view in subview.subviews { if let btn = view as? UIButton { btn.isUserInteractionEnabled = false; } } } return cell!; } case .emails: if indexPath.row < vcard?.emails.count ?? 0 { let cell = tableView.dequeueReusableCell(withIdentifier: "EmailEditCell") as! VCardEditEmailTableViewCell; cell.email = vcard?.emails[indexPath.row]; return cell; } else { let cell = tableView.dequeueReusableCell(withIdentifier: "EmailAddCell"); for subview in cell!.subviews { for view in subview.subviews { if let btn = view as? UIButton { btn.isUserInteractionEnabled = false; } } } return cell!; } case .addresses: if indexPath.row < vcard?.addresses.count ?? 0 { let cell = tableView.dequeueReusableCell(withIdentifier: "AddressEditCell") as! VCardEditAddressTableViewCell; cell.address = vcard?.addresses[indexPath.row]; return cell; } else { let cell = tableView.dequeueReusableCell(withIdentifier: "AddressAddCell"); for subview in cell!.subviews { for view in subview.subviews { if let btn = view as? UIButton { btn.isUserInteractionEnabled = false; } } } return cell!; } } } override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { (cell as? VCardAvatarEditCell)?.updateCornerRadius(); } override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { switch VCardSections(rawValue: indexPath.section)! { case .basic: return false; case .phones: return indexPath.row < vcard?.telephones.count ?? 0; case .emails: return indexPath.row < vcard?.emails.count ?? 0; case .addresses: return indexPath.row < vcard?.addresses.count ?? 0; } } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch section { case 0: return 7; case 1: return (vcard?.telephones.count ?? 0) + 1; case 2: return (vcard?.emails.count ?? 0) + 1; case 3: return (vcard?.addresses.count ?? 0) + 1; default: return 0; } } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { switch VCardSections(rawValue: section)! { case .basic: return nil; case .phones: return NSLocalizedString("Phones", comment: "vcard section label"); case .emails: return NSLocalizedString("Emails", comment: "vcard section label"); case .addresses: return NSLocalizedString("Addresses", comment: "vcard section label"); } } override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { if VCardSections(rawValue: section)! == .basic { return 1.0; } return super.tableView(tableView, heightForHeaderInSection: section); } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true); guard let vcard = self.vcard else { return; } switch VCardSections(rawValue: indexPath.section)! { case .basic: if indexPath.row == VCardBaseSectionRows.avatar.rawValue { self.photoClicked(); } return; case .phones: if indexPath.row == vcard.telephones.count { vcard.telephones.append(VCard.Telephone(uri: nil, types: [.home])); tableView.reloadData(); } return; case .emails: if indexPath.row == vcard.emails.count { vcard.emails.append(VCard.Email(address: nil, types: [.home])); tableView.reloadData(); } case .addresses: if indexPath.row == vcard.addresses.count { vcard.addresses.append(VCard.Address(types: [.home])); tableView.reloadData(); } return; } } override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { guard let vcard = self.vcard else { return; } if editingStyle == UITableViewCell.EditingStyle.delete { if indexPath.section == 1 { vcard.telephones.remove(at: indexPath.row); tableView.reloadData(); } if indexPath.section == 2 { vcard.emails.remove(at: indexPath.row); tableView.reloadData(); } if indexPath.section == 3 { vcard.addresses.remove(at: indexPath.row); tableView.reloadData(); } } } override func numberOfSections(in tableView: UITableView) -> Int { return 4; } @IBAction func refreshVCard(_ sender: UIBarButtonItem) { VCardManager.instance.refreshVCard(for: client.userBareJid, on: client.userBareJid, completionHandler: { result in switch result { case .success(let vcard): DispatchQueue.main.async { self.vcard = vcard; } case .failure(_): break; } }); } @IBAction func publishVCard(_ sender: UIBarButtonItem) { self.tableView.endEditing(true); DispatchQueue.main.async { DispatchQueue.global(qos: .default).async { guard let vcard = self.vcard, let client = self.client else { return; } self.publishVCard(vcard: vcard, completionHandler: { result in switch result { case .success(_): DispatchQueue.main.async() { _ = self.navigationController?.popViewController(animated: true); } DBVCardStore.instance.updateVCard(for: self.client.userBareJid, on: self.client.userBareJid, vcard: vcard); if let photo = self.vcard?.photos.first { VCardManager.instance.fetchPhoto(photo: photo, completionHandler: { result in switch result { case .success(let data): let avatarHash = Digest.sha1.digest(toHex: data); let x = Element(name: "x", xmlns: "vcard-temp:x:update"); x.addChild(Element(name: "photo", cdata: avatarHash)); client.module(.presence).setPresence(show: .online, status: nil, priority: nil, additionalElements: [x]); case .failure(_): break; } }); } case .failure(let errorCondition): DispatchQueue.main.async { self.tableView.setEditing(true, animated: true); let alertController = UIAlertController(title: NSLocalizedString("Failure", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("VCard publication failed: %@", comment: "alert body"), errorCondition.localizedDescription), preferredStyle: .alert); alertController.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); self.present(alertController, animated: true, completion: nil); } } }) } } } private func publishVCard(vcard: VCard, completionHandler: @escaping (Result)->Void) { publishVCard(vcard: vcard, module: client.module(.vcard4), completionHandler: { [weak self] result in switch result { case .success(let void): completionHandler(.success(void)); case .failure(let error): guard let that = self else { completionHandler(.failure(error)); return; } that.publishVCard(vcard: vcard, module: that.client.module(.vcardTemp), completionHandler: completionHandler); } }); } private func publishVCard(vcard: VCard, module: VCardModuleProtocol, completionHandler: @escaping (Result)->Void) { module.publishVCard(vcard, completionHandler: completionHandler); } @objc func photoClicked() { if UIImagePickerController.isSourceTypeAvailable(.camera) { let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet); alert.addAction(UIAlertAction(title: NSLocalizedString("Take photo", comment: "photo selection action"), style: .default, handler: { (action) in self.selectPhoto(.camera); })); alert.addAction(UIAlertAction(title: NSLocalizedString("Select photo", comment: "photo selection action"), style: .default, handler: { (action) in self.selectPhoto(.photoLibrary); })); alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); let cell = self.tableView(tableView, cellForRowAt: IndexPath(row: VCardBaseSectionRows.avatar.rawValue, section: VCardSections.basic.rawValue)) as! VCardAvatarEditCell; alert.popoverPresentationController?.sourceView = cell.avatarView; alert.popoverPresentationController?.sourceRect = cell.avatarView!.bounds; present(alert, animated: true, completion: nil); } else { selectPhoto(.photoLibrary); } } func selectPhoto(_ source: UIImagePickerController.SourceType) { picker.delegate = self; picker.allowsEditing = true; picker.sourceType = source; present(picker, animated: true, completion: nil); } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { guard let photo = (info[UIImagePickerController.InfoKey.editedImage] as? UIImage) else { return; } guard let pngImage = photo.scaled(maxWidthOrHeight: 48), let pngData = pngImage.pngData() else { return; } var items: [PEPUserAvatarModule.Avatar] = [.init(data: pngData, mimeType: "image/png", width: Int(pngImage.size.width), height: Int(pngImage.size.height))]; if let jpegImage = photo.scaled(maxWidthOrHeight: 256), let jpegData = jpegImage.jpegData(compressionQuality: 0.8) { items = [.init(data: jpegData, mimeType: "image/jpeg", width: Int(jpegImage.size.width), height: Int(jpegImage.size.height))] + items; } if let item = items.first { vcard?.photos = [VCard.Photo(type: item.info.mimeType, binval: item.data!.base64EncodedString(options: NSData.Base64EncodingOptions(rawValue: 0)))]; } tableView.reloadData(); picker.dismiss(animated: true, completion: nil); let pepUserAvatarModule = client.module(.pepUserAvatar); if pepUserAvatarModule.isPepAvailable { let question = UIAlertController(title: nil, message: NSLocalizedString("Do you wish to publish this photo as avatar?", comment: "alert body"), preferredStyle: .actionSheet); question.addAction(UIAlertAction(title: NSLocalizedString("Yes", comment: "button label"), style: .default, handler: { (action) in pepUserAvatarModule.publishAvatar(avatar: items, completionHandler: { result in switch result { case .success(_): break; case .failure(let error): DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Error", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("User avatar publication failed.\nReason: %@", comment: "alert body"), error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); self.present(alert, animated: true, completion: nil); } } }) })); question.addAction(UIAlertAction(title: NSLocalizedString("No", comment: "button label"), style: .cancel, handler: nil)); let cell = self.tableView(tableView, cellForRowAt: IndexPath(row: VCardBaseSectionRows.avatar.rawValue, section: VCardSections.basic.rawValue)) as! VCardAvatarEditCell; question.popoverPresentationController?.sourceView = cell.avatarView; question.popoverPresentationController?.sourceRect = cell.avatarView!.bounds; present(question, animated: true, completion: nil); } } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { picker.dismiss(animated: true, completion: nil); } @objc func bdayValueChanged(_ sender: UIDatePicker) { let formatter = DateFormatter(); formatter.timeStyle = .none; formatter.dateFormat = "yyyy-MM-dd"; let string = formatter.string(from: sender.date); if let cell = tableView.cellForRow(at: IndexPath(row: VCardBaseSectionRows.birthday.rawValue, section: VCardSections.basic.rawValue)) as? VCardTextEditCell { cell.textField.text = string; } vcard?.bday = string; } func textFieldDidEndEditing(_ textField: UITextField) { let text = textField.text; if let row = VCardBaseSectionRows(rawValue: textField.tag) { switch row { case .givenName: vcard?.givenName = text; case .familyName: vcard?.surname = text; case .fullName: vcard?.fn = text; case .organization: vcard?.organizations = (text?.isEmpty ?? true) ? [] : [VCard.Organization(name: text!, types: [.work])]; case .organizationRole: vcard?.role = text; default: break; } } } enum VCardSections: Int { case basic = 0 case phones = 1 case emails = 2 case addresses = 3 } enum VCardBaseSectionRows: Int { case avatar = 0 case givenName = 1 case familyName = 2 case fullName = 3 case birthday = 4 case organization = 5 case organizationRole = 6 } } // Helper function inserted by Swift 4.2 migrator. fileprivate func convertFromUIImagePickerControllerInfoKey(_ input: UIImagePickerController.InfoKey) -> String { return input.rawValue } ================================================ FILE: SiskinIM/vcard/VCardEntryTypeAwareTableViewCell.swift ================================================ // // VCardEntryTypeAwareTableViewCell.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Martin class VCardEntryTypeAwareTableViewCell: UITableViewCell, UIPickerViewDelegate, UIPickerViewDataSource { @IBOutlet var typeView: UITextField! override func awakeFromNib() { super.awakeFromNib() // Initialization code let typePicker = UIPickerView(); typePicker.dataSource = self; typePicker.delegate = self; typeView.inputView = typePicker; if #available(iOS 13.0, *) { let btn = UIButton(type: .detailDisclosure); btn.isEnabled = false; btn.setImage(UIImage(systemName: "chevron.right"), for: .normal); typeView.rightView = btn; typeView.rightViewMode = .always; } } override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) // Configure the view for the selected state } func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { let type = VCard.EntryType.allValues[row]; typeSelected(type); typeView.text = vcardEntryTypeName(for: type); } func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { let type = VCard.EntryType.allValues[row]; return vcardEntryTypeName(for: type); } func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? { guard let title = self.pickerView(pickerView, titleForRow: row, forComponent: component) else { return nil; } if #available(iOS 13.0, *) { return NSAttributedString(string: title, attributes: [.foregroundColor : UIColor.label]); } else { return NSAttributedString(string: title, attributes: [.foregroundColor : UIColor.darkText]); } } func numberOfComponents(in pickerView: UIPickerView) -> Int { return 1; } func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { return VCard.EntryType.allValues.count; } func typeSelected(_ type: VCard.EntryType) { } func vcardEntryTypeName(for type: VCard.EntryType?) -> String? { guard type != nil else { return nil; } switch type! { case .home: return NSLocalizedString("Home", comment: "address type label"); case .work: return NSLocalizedString("Work", comment: "address type label"); } } } ================================================ FILE: SiskinIM/vcard/VCardTextEditCell.swift ================================================ // // VCardTextEditCell.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit class VCardTextEditCell: UITableViewCell { @IBOutlet var textField: UITextField!; } ================================================ FILE: SiskinIM/voip/CallManager.swift ================================================ // // CallManager.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import CallKit import PushKit import WebRTC import Martin import TigaseLogging import Shared import Combine import Intents class CallManager: NSObject, CXProviderDelegate { static var isAvailable: Bool { let userLocale = NSLocale.current if (userLocale.regionCode?.contains("CN") ?? false) || (userLocale.regionCode?.contains("CHN") ?? false) { return false } else { return true } } private(set) static var instance: CallManager? = nil; static func initializeCallManager() { if isAvailable { if instance == nil { instance = CallManager(); } } else { instance = nil; } } private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "CallManager") private let pushRegistry: PKPushRegistry; private let provider: CXProvider; private let callController: CXCallController; private let dispatcher = QueueDispatcher(label: "CallManager"); @Published private var activeCalls: [CallBase] = []; private var activeCallsByUuid: [UUID: CallBase] = [:]; private var cancellables: Set = []; private override init() { let config = CXProviderConfiguration(localizedName: "SiskinIM"); if #available(iOS 13.0, *) { if let image = UIImage(systemName: "message.fill") { config.iconTemplateImageData = image.pngData(); } } else { if let image = UIImage(named: "message.fill") { config.iconTemplateImageData = image.pngData(); } } config.includesCallsInRecents = false; config.supportsVideo = true; config.maximumCallsPerCallGroup = 1; config.maximumCallGroups = 1; config.supportedHandleTypes = [.generic]; provider = CXProvider(configuration: config); callController = CXCallController(); pushRegistry = PKPushRegistry(queue: nil); super.init(); provider.setDelegate(self, queue: nil); pushRegistry.delegate = self; pushRegistry.desiredPushTypes = [.voIP]; $activeCalls.map({ !$0.isEmpty }).assign(to: \.onCall, on: XmppService.instance).store(in: &cancellables); $activeCalls.map({ !$0.isEmpty }).sink(receiveValue: { callInProgress in DispatchQueue.main.async { UIApplication.shared.isIdleTimerDisabled = callInProgress; } }).store(in: &cancellables); } // private func changeCallState(_ state: Call.State) { // currentCall?.state = state; // delegate?.callStateChanged(self); // } func reportIncomingCall(_ call: CallBase, completionHandler: @escaping(Result)->Void) { dispatcher.sync { guard self.activeCalls.allSatisfy({ !call.isEqual($0) }) else { completionHandler(.failure(XMPPError.conflict("Call already registered!"))); return; } if let c = call as? Call { if let meet = self.activeCalls.compactMap({ m -> Meet? in guard m.account == c.account && m.jid == c.jid, let meet = m as? Meet else { return nil; } return meet; }).first { // we have found a meet for this account-jid pair.. self.dispatcher.sync { c.ringing(); } meet.setIncomingCall(c); completionHandler(.success(Void())); return; } } if #available(iOS 15.0, *) { let sender = INPerson(personHandle: INPersonHandle(value: call.jid.stringValue, type: .unknown), nameComponents: nil, displayName: call.name, image: AvatarManager.instance.avatar(for: call.jid, on: call.account)?.inImage(), contactIdentifier: nil, customIdentifier: call.jid.stringValue, isMe: false, suggestionType: .instantMessageAddress); let intent = INStartCallIntent(callRecordFilter: nil, callRecordToCallBack: nil, audioRoute: .unknown, destinationType: .unknown, contacts: [sender], callCapability: AVCaptureDevice.authorizationStatus(for: .video) == .authorized && call.media.contains(.video) ? .videoCall : .audioCall) let interaction = INInteraction(intent: intent, response: nil); interaction.direction = .incoming; interaction.donate(completion: nil); } self.activeCalls.append(call); #if targetEnvironment(simulator) call.accept(offerMedia: call.media, completionHandler: completionHandler); #else let update = CXCallUpdate(); update.remoteHandle = call.remoteHandle; update.localizedCallerName = call.name; update.hasVideo = AVCaptureDevice.authorizationStatus(for: .video) == .authorized && call.media.contains(.video); self.logger.debug("reporting incoming call: \(call.uuid)") self.provider.reportNewIncomingCall(with: call.uuid, update: update, completion: { err in guard let error = err else { self.activeCallsByUuid[call.uuid] = call; self.dispatcher.sync { call.ringing(); } completionHandler(.success(Void())); return; } self.callEnded(call); guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { completionHandler(.failure(ErrorCondition.not_authorized)); AVCaptureDevice.requestAccess(for: .audio, completionHandler: { _ in }); if call.media.contains(.video) { AVCaptureDevice.requestAccess(for: .video, completionHandler: { _ in }); } return; } completionHandler(.failure(error)); }) #endif } } func reportOutgoingCall(_ call: CallBase, completionHandler: @escaping(Result)->Void) { dispatcher.async { guard self.activeCalls.allSatisfy({ !call.isEqual($0) }) else { completionHandler(.failure(XMPPError.conflict("Call already registered!"))); return; } self.activeCalls.append(call); if #available(iOS 15.0, *) { let recipient = INPerson(personHandle: INPersonHandle(value: call.jid.stringValue, type: .unknown), nameComponents: nil, displayName: call.name, image: AvatarManager.instance.avatar(for: call.jid, on: call.account)?.inImage(), contactIdentifier: nil, customIdentifier: call.jid.stringValue, isMe: false, suggestionType: .instantMessageAddress); let intent = INStartCallIntent(callRecordFilter: nil, callRecordToCallBack: nil, audioRoute: .unknown, destinationType: .unknown, contacts: [recipient], callCapability: AVCaptureDevice.authorizationStatus(for: .video) == .authorized && call.media.contains(.video) ? .videoCall : .audioCall) let interaction = INInteraction(intent: intent, response: nil); interaction.direction = .incoming; interaction.donate(completion: nil); } let startCallAction = CXStartCallAction(call: call.uuid, handle: call.remoteHandle); startCallAction.isVideo = call.media.contains(.video); startCallAction.contactIdentifier = call.name; let transaction = CXTransaction(action: startCallAction); self.logger.debug("reporting outgoing call: \(call.uuid)") self.callController.request(transaction, completion: { err in guard let error = err else { self.activeCallsByUuid[call.uuid] = call; call.ringing(); completionHandler(.success(Void())); return; } self.callEnded(call); completionHandler(.failure(error)); }); } } func providerDidReset(_ provider: CXProvider) { self.logger.debug("provider did reset!"); } func provider(_ provider: CXProvider, perform action: CXStartCallAction) { self.logger.debug("starting call: \(action.uuid)") guard let call = activeCallsByUuid[action.callUUID] else { action.fail(); return; } call.start(completionHandler: { result in switch result { case .success(_): action.fulfill(withDateStarted: Date()); case .failure(_): action.fail(); self.callEnded(call); call.reset(); } }); } func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { self.logger.debug("answering call: \(action.uuid)") guard let call = activeCallsByUuid[action.callUUID] else { action.fail(); return; } // here we should wait till XMPPClient is connected.. DispatchQueue.main.async { self.accountConnected = { account in guard XmppService.instance.getClient(for: call.account)?.state == .connected() else { return; } self.accountConnected = nil; call.accept(offerMedia: call.media, completionHandler: { result in switch result { case .success(_): action.fulfill(); case .failure(let error): self.callEnded(call); action.fail(); } }); } self.connectionEstablished(for: call.account); } } private var accountConnected: ((BareJID)->Void)?; func connectionEstablished(for account: BareJID) { DispatchQueue.main.async { self.accountConnected?(account); } } func provider(_ provider: CXProvider, perform action: CXEndCallAction) { self.logger.debug("ending call: \(action.uuid)") guard let call = activeCallsByUuid[action.callUUID] else { action.fail(); return; } call.end(); callEnded(call); action.fulfill(); } func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { guard let call = activeCallsByUuid[action.callUUID] else { action.fail(); return; } call.mute(value: action.isMuted); action.fulfill(); } func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) { self.logger.debug("operation timed out! for: \(action.uuid)"); } static func showCallController(completionHandler: (VideoCallController)->Void) { var topController = UIApplication.shared.windows.first(where: { $0.isKeyWindow })?.rootViewController; while (topController?.presentedViewController != nil) { topController = topController?.presentedViewController; } let controller = UIStoryboard(name: "VoIP", bundle: nil).instantiateViewController(withIdentifier: "VideoCallController") as! VideoCallController; controller.modalPresentationStyle = .fullScreen; topController?.present(controller, animated: true); // topController?.show(controller, sender: self); completionHandler(controller); } func muteCall(_ call: Call, value: Bool) { let muteCallAction = CXSetMutedCallAction(call: call.uuid, muted: value); callController.request(CXTransaction(action: muteCallAction), completion: { error in if let error = error { fatalError(error.localizedDescription) } }); } func endCall(_ call: CallBase) { guard activeCallsByUuid[call.uuid] != nil else { return; } let endCallAction = CXEndCallAction(call: call.uuid); let transaction = CXTransaction(action: endCallAction); callController.request(transaction) { error in if let error = error { #if targetEnvironment(simulator) call.reset(); #else fatalError(error.localizedDescription) #endif } } } func endCall(on account: BareJID, with jid: BareJID, sid: String, completionHandler: @escaping ()->Void) { logger.debug("endCall(on account) called"); dispatcher.async { guard let call = self.activeCalls.first(where: { $0.account == account && $0.jid == jid && $0.sid == sid }) else { completionHandler(); return; } let endCallAction = CXEndCallAction(call: call.uuid); let transaction = CXTransaction(action: endCallAction); self.callController.request(transaction) { error in call.reset(); completionHandler(); } } } func endCall(on account: BareJID, sid: String, completionHandler: (()->Void)? = nil) { logger.debug("endCall(on account) called"); dispatcher.async { guard let call = self.activeCalls.first(where: { $0.account == account && $0.sid == sid }) else { completionHandler?(); return; } let endCallAction = CXEndCallAction(call: call.uuid); let transaction = CXTransaction(action: endCallAction); self.callController.request(transaction) { error in call.reset(); completionHandler?(); } } } private func callEnded(_ call: CallBase) { self.activeCallsByUuid.removeValue(forKey: call.uuid); if let idx = self.activeCalls.firstIndex(where: { $0 === call }) { self.activeCalls.remove(at: idx); } } } protocol CallBase: AnyObject, CustomStringConvertible { var account: BareJID { get } var jid: BareJID { get } var sid: String { get } var uuid: UUID { get } var name: String { get } var remoteHandle: CXHandle { get } var media: [Call.Media] { get } func isEqual(_ call: CallBase) -> Bool; func reset(); func start(completionHandler: @escaping (Result)->Void); func accept(offerMedia: [Call.Media], completionHandler: @escaping (Result)->Void); func ringing(); func end(); func mute(value: Bool); } class Call: NSObject, CallBase, JingleSessionActionDelegate { let uuid = UUID(); var name: String { return DBRosterStore.instance.item(for: client, jid: JID(jid))?.name ?? jid.stringValue; } var remoteHandle: CXHandle { return CXHandle(type: .generic, value: jid.stringValue); } let client: XMPPClient; let jid: BareJID; let sid: String; let direction: Direction; let media: [Media] var account: BareJID { return client.userBareJid; } private(set) var state: State = .new; var webrtcSid: String?; private(set) var currentConnection: RTCPeerConnection?; weak var delegate: CallDelegate? { didSet { delegate?.callDidStart(self); } } fileprivate(set) var session: JingleManager.Session? { didSet { session?.$state.removeDuplicates().sink(receiveValue: { [weak self] state in guard let that = self else { return; } switch state { case .accepted: switch that.direction { case .incoming: break; case .outgoing: that.acceptedOutgingCall(); } case .terminated: that.sessionTerminated() default: break; } }).store(in: &cancellables); } } private var establishingSessions: [JingleManager.Session] = []; private var localCandidates: [RTCIceCandidate] = []; private(set) var localVideoSource: RTCVideoSource?; private(set) var localVideoTrack: RTCVideoTrack?; private(set) var localAudioTrack: RTCAudioTrack?; #if targetEnvironment(simulator) private(set) var localCapturer: RTCFileVideoCapturer?; #else private(set) var localCapturer: RTCCameraVideoCapturer?; #endif private(set) var localCameraDeviceID: String?; private var cancellables: Set = []; override var description: String { return "Call[on: \(client.userBareJid), with: \(jid), sid: \(sid), id: \(uuid)]"; } private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "voip"); init(client: XMPPClient, with jid: BareJID, sid: String, direction: Direction, media: [Media]) { self.client = client; self.jid = jid; self.sid = sid; self.media = media; self.direction = direction; } func isEqual(_ call: CallBase) -> Bool { guard let c = call as? Call else { return false; } return c.account == account && c.jid == jid && c.sid == sid; } func start(completionHandler: @escaping (Result)->Void) { DispatchQueue.main.async { CallManager.showCallController(completionHandler: { controller in self.delegate = controller; }); } initiateOutgoingCall(completionHandler: completionHandler); } func accept(offerMedia: [Media], completionHandler: @escaping (Result) -> Void) { DispatchQueue.main.async { CallManager.showCallController(completionHandler: { controller in self.delegate = controller; }); } self.accept(offerMedia: media); completionHandler(.success(Void())); } func end() { if self.state == .new || self.state == .ringing { self.reject(); } else { self.reset(); } } func mute(value: Bool) { self.localAudioTrack?.isEnabled = !value; self.localVideoTrack?.isEnabled = !value; let infos: [Jingle.SessionInfo] = self.localSessionDescription?.contents.filter({ $0.description?.media == "audio" || $0.description?.media == "video" }).map({ $0.name }).map({ value ? .mute(contentName: $0) : .unmute(contentName: $0) }) ?? []; if !infos.isEmpty { session?.sessionInfo(infos); } } func ringing() { if direction == .incoming { session = JingleManager.instance.session(forCall: self); } webrtcSid = String(UInt64.random(in: UInt64.min...UInt64.max)); changeState(.ringing); } func reset() { DispatchQueue.main.async { self.currentConnection?.close(); self.currentConnection = nil; if self.localCapturer != nil { #if targetEnvironment(simulator) self.localCapturer?.stopCapture(); #else self.logger.debug("\(self), stopping local capturer: \(self.localCapturer)"); self.localCapturer?.stopCapture(completionHandler: { self.localCapturer = nil; }) #endif } self.localVideoTrack = nil; self.localAudioTrack = nil; self.localVideoSource = nil; self.delegate?.callDidEnd(self); _ = self.session?.terminate(); self.session = nil; self.delegate = nil; for session in self.establishingSessions { session.terminate(); } self.establishingSessions.removeAll(); self.state = .ended; } } enum Media: String { case audio case video static func from(string: String?) -> Media? { guard let v = string else { return nil; } return Media(rawValue: v); } var avmedia: AVMediaType { switch self { case .audio: return .audio case .video: return .video; } } } enum Direction { case incoming case outgoing } enum State { case new case ringing case connecting case connected case ended } func initiateOutgoingCall(with callee: JID? = nil, completionHandler: @escaping (Result)->Void) { guard let client = XmppService.instance.getClient(for: account) else { completionHandler(.failure(ErrorCondition.item_not_found)); return; } var withJingle: [JID] = []; var withJMI: [JID] = []; if let jid = callee { withJingle.append(jid); } else { let presences = PresenceStore.instance.presences(for: jid, context: client); for presence in presences { if let jid = presence.from, let capsNode = presence.capsNode { if let features = DBCapabilitiesCache.instance.getFeatures(for: capsNode) { if features.contains(JingleModule.XMLNS) && features.contains(Jingle.Transport.ICEUDPTransport.XMLNS) && features.contains("urn:xmpp:jingle:apps:rtp:audio") { withJingle.append(jid); if features.contains(JingleModule.MESSAGE_INITIATION_XMLNS) { withJMI.append(jid); } } } } } } self.changeState(.ringing); initiateWebRTC(offerMedia: media, completionHandler: { result in switch result { case .success(_): completionHandler(.success(Void())); if withJMI.count == withJingle.count { let session = JingleManager.instance.open(for: client, with: JID(self.jid), sid: self.sid, role: .initiator, initiationType: .message); self.session = session; _ = session.initiate(descriptions: self.media.map({ Jingle.MessageInitiationAction.Description(xmlns: "urn:xmpp:jingle:apps:rtp:1", media: $0.rawValue) })); } else { // we need to establish multiple 1-1 sessions... guard let peerConnection = self.currentConnection else { return; } self.generateOfferAndSet(peerConnection: peerConnection, creatorProvider: { _ in Jingle.Content.Creator.initiator }, localRole: .initiator, completionHandler: { result in switch result { case .failure(_): self.reset(); case .success(let sdp): DispatchQueue.main.async { for jid in withJingle { let session = JingleManager.instance.open(for: client, with: jid, sid: self.sid, role: .initiator, initiationType: .iq); session.$state.removeDuplicates().receive(on: DispatchQueue.main).sink(receiveValue: { state in switch state { case .accepted: guard self.session == nil else { session.terminate(); return; } for sess in self.establishingSessions { if sess.account == session.account && sess.jid == session.jid && sess.sid == session.sid { } else { sess.terminate(); } } self.establishingSessions.removeAll(); self.session = session; self.state = .connecting; self.connectRemoteSDPPublishers(session: session); self.sendLocalCandidates(); case .terminated: if let idx = self.establishingSessions.firstIndex(where: { $0.account == session.account && $0.jid == session.jid && $0.sid == session.sid }) { self.establishingSessions.remove(at: idx); } if self.establishingSessions.isEmpty && self.session == nil { self.reset(); } default: break; } }).store(in: &self.cancellables); self.establishingSessions.append(session); _ = session.initiate(contents: sdp.contents, bundle: sdp.bundle); } } } }) } case .failure(let err): completionHandler(.failure(err)); } }) } private func acceptedOutgingCall() { guard let session = session, session.initiationType == .message, state == .ringing, let peerConnection = self.currentConnection else { return; } changeState(.connecting); generateOfferAndSet(peerConnection: peerConnection, creatorProvider: session.contentCreator(of:), localRole: session.role, completionHandler: { result in switch result { case .success(let sdp): guard let session = self.session else { self.reset(); return } self.connectRemoteSDPPublishers(session: session); _ = session.initiate(contents: sdp.contents, bundle: sdp.bundle); case .failure(_): self.reset(); } }); } private func connectRemoteSDPPublishers(session: JingleManager.Session) { session.setDelegate(self); } static let VALID_SERVICE_TYPES = ["stun", "stuns", "turn", "turns"]; func initiateWebRTC(offerMedia media: [Media], completionHandler: @escaping (Result)->Void) { if let module: ExternalServiceDiscoveryModule = XmppService.instance.getClient(for: self.account)?.module(.externalServiceDiscovery), module.isAvailable { module.discover(from: nil, type: nil, completionHandler: { result in switch result { case .success(let services): var servers: [RTCIceServer] = []; for service in services { if let server = service.rtcIceServer() { servers.append(server); } } self.initiateWebRTC(iceServers: servers, offerMedia: media, completionHandler: completionHandler); case .failure(_): self.initiateWebRTC(iceServers: [], offerMedia: media, completionHandler: completionHandler); } }) } else { initiateWebRTC(iceServers: [], offerMedia: media, completionHandler: completionHandler); } } #if targetEnvironment(simulator) // var repeatingVideoTimer: Timer?; #endif var audioSession: AudioSesion?; private func initiateWebRTC(iceServers: [RTCIceServer], offerMedia media: [Media], completionHandler: @escaping (Result)->Void) { self.currentConnection = VideoCallController.initiatePeerConnection(iceServers: iceServers, withDelegate: self); if self.currentConnection != nil { if media.contains(.audio) { let avsession = AVAudioSession.sharedInstance() do { try avsession.setCategory(.playAndRecord, mode: media.contains(.video) ? .videoChat : .voiceChat, options: [.allowBluetooth,.allowBluetoothA2DP]) try avsession.setPreferredIOBufferDuration(0.005) //try avsession.setPreferredSampleRate(4_410) } catch { fatalError(error.localizedDescription) } self.localAudioTrack = VideoCallController.peerConnectionFactory.audioTrack(withTrackId: "audio-" + UUID().uuidString); if let localAudioTrack = self.localAudioTrack { self.currentConnection?.add(localAudioTrack, streamIds: ["RTCmS"]); } } #if targetEnvironment(simulator) let hasAvPermission = true; #else let hasAvPermission = AVCaptureDevice.authorizationStatus(for: .video) == .authorized; #endif if media.contains(.video) && hasAvPermission { let videoSource = VideoCallController.peerConnectionFactory.videoSource(); self.localVideoSource = videoSource; let localVideoTrack = VideoCallController.peerConnectionFactory.videoTrack(with: videoSource, trackId: "video-" + UUID().uuidString); self.localVideoTrack = localVideoTrack; #if targetEnvironment(simulator) let localVideoCapturer = RTCFileVideoCapturer(delegate: videoSource) #else let localVideoCapturer = RTCCameraVideoCapturer(delegate: videoSource); #endif self.localCapturer = localVideoCapturer; #if targetEnvironment(simulator) localVideoCapturer.startCapturing(fromFileNamed: "foreman.mp4", onError: { error in self.logger.debug("failed to start video capturer: \(error)"); }); self.delegate?.call(self, didReceiveLocalVideoTrack: localVideoTrack); self.currentConnection?.add(localVideoTrack, streamIds: ["RTCmS"]); completionHandler(.success(Void())) #else if let device = RTCCameraVideoCapturer.captureDevices().first(where: { $0.position == .front }), let format = RTCCameraVideoCapturer.format(for: device, preferredOutputPixelFormat: localVideoCapturer.preferredOutputPixelFormat()) { self.logger.debug("\(self), starting video capture on: \(device), with: \(format), fps: \(RTCCameraVideoCapturer.fps(for: format))"); self.localCameraDeviceID = device.uniqueID; localVideoCapturer.startCapture(with: device, format: format, fps: RTCCameraVideoCapturer.fps(for: format), completionHandler: { error in self.logger.debug("\(self), video capturer started!"); }); self.delegate?.call(self, didReceiveLocalVideoTrack: localVideoTrack); self.currentConnection?.add(localVideoTrack, streamIds: ["RTCmS"]); completionHandler(.success(Void())); } else { completionHandler(.failure(ErrorCondition.not_authorized)); } #endif } else { completionHandler(.success(Void())); } } else { completionHandler(.failure(ErrorCondition.internal_server_error)); } } func accept(offerMedia media: [Media]) { guard let session = self.session else { reset(); return; } changeState(.connecting); initiateWebRTC(offerMedia: media, completionHandler: { result in switch result { case .success(_): guard self.currentConnection != nil else { self.reject(); return; } session.accept(); self.connectRemoteSDPPublishers(session: session); case .failure(_): // there was an error, so we should reject this call self.reject(); } }) } func reject() { guard let session = self.session else { reset(); return; } session.decline(); reset(); } private var localSessionDescription: SDP?; private var remoteSessionDescription: SDP?; private let remoteSessionSemaphore = DispatchSemaphore(value: 1); public func received(action: JingleManager.Session.Action) { guard let peerConnection = self.currentConnection, let session = self.session else { return; } remoteSessionSemaphore.wait(); if case let .transportAdd(candidate, contentName) = action { if let idx = remoteSessionDescription?.contents.firstIndex(where: { $0.name == contentName }) { peerConnection.add(RTCIceCandidate(sdp: candidate.toSDP(), sdpMLineIndex: Int32(idx), sdpMid: contentName)); } remoteSessionSemaphore.signal(); return; } let result = apply(action: action, on: self.remoteSessionDescription); guard let newSDP = result else { remoteSessionSemaphore.signal(); return; } let prevLocalSDP = self.localSessionDescription; setRemoteDescription(newSDP, peerConnection: peerConnection, session: session, completionHandler: { result in self.remoteSessionSemaphore.signal(); switch result { case .failure(let error): self.logger.error("error setting remote description: \(error)"); self.reset(); case .success(let localSDP): if let sdp = localSDP { if prevLocalSDP != nil { let changes = sdp.diff(from: prevLocalSDP!); if let addSDP = changes[.add] { _ = session.contentModify(action: .accept, contents: addSDP.contents, bundle: addSDP.bundle); } if let modifySDP = changes[.modify] { // can we safely ignore this? } } else { _ = session.accept(contents: sdp.contents, bundle: sdp.bundle) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { self.sendLocalCandidates(); }) } } break; } }); } private func apply(action: JingleManager.Session.Action, on prevSDP: SDP?) -> SDP? { switch action { case .contentSet(let newSDP): return newSDP; case .contentApply(let action, let diffSDP): switch action { case .add, .accept, .remove, .modify: return prevSDP?.applyDiff(action: action, diff: diffSDP); } case .transportAdd(_, _): return nil; case .sessionInfo(let infos): for info in infos { logger.debug("\(self), received session info: \(String(describing: info))") } return nil; } } private func setRemoteDescription(_ remoteDescription: SDP, peerConnection: RTCPeerConnection, session: JingleSession, completionHandler: @escaping (Result)->Void) { logger.debug("\(self), setting remote description: \(remoteDescription.toString(withSid: "", localRole: session.role, direction: .incoming))"); peerConnection.setRemoteDescription(RTCSessionDescription(type: self.direction == .incoming ? .offer : .answer, sdp: remoteDescription.toString(withSid: self.webrtcSid!, localRole: session.role, direction: .incoming)), completionHandler: { err in guard let error = err else { self.remoteSessionDescription = remoteDescription; if peerConnection.signalingState == .haveRemoteOffer { self.generateAnswerAndSet(peerConnection: peerConnection, creatorProvider: session.contentCreator(of:), localRole: session.role, completionHandler: { result in switch result { case .success(let localSDP): completionHandler(.success(localSDP)); case .failure(let error): completionHandler(.failure(error)); } }) } else { completionHandler(.success(nil)); } return; } completionHandler(.failure(error)); }); } private func generateOfferAndSet(peerConnection: RTCPeerConnection, creatorProvider: @escaping (String)->Jingle.Content.Creator, localRole: Jingle.Content.Creator, completionHandler: @escaping (Result)->Void) { logger.debug("\(self), generating offer"); peerConnection.offer(for: VideoCallController.defaultCallConstraints, completionHandler: { sdpOffer, err in guard let error = err else { self.setLocalDescription(peerConnection: peerConnection, sdp: sdpOffer!, creatorProvider: creatorProvider, localRole: localRole, completionHandler: completionHandler); return; } completionHandler(.failure(error)); }); }; private func generateAnswerAndSet(peerConnection: RTCPeerConnection, creatorProvider: @escaping (String)->Jingle.Content.Creator, localRole: Jingle.Content.Creator, completionHandler: @escaping (Result)->Void) { logger.debug("\(self), generating answer"); peerConnection.answer(for: VideoCallController.defaultCallConstraints, completionHandler: { sdpAnswer, err in guard let error = err else { self.setLocalDescription(peerConnection: peerConnection, sdp: sdpAnswer!, creatorProvider: creatorProvider, localRole: localRole, completionHandler: completionHandler); return; } completionHandler(.failure(error)); }); } private func setLocalDescription(peerConnection: RTCPeerConnection, sdp localSDP: RTCSessionDescription, creatorProvider: @escaping (String)->Jingle.Content.Creator, localRole: Jingle.Content.Creator, completionHandler: @escaping (Result)->Void) { logger.debug("\(self), setting local description: \(localSDP.sdp)"); peerConnection.setLocalDescription(localSDP, completionHandler: { err in guard let error = err else { // session may be unavailable, and we need it to get content creator from it.. or we may have many sessions for a single RTCPeerConnection (initiating outgoing call using plain Jingle) guard let (sdp, _) = SDP.parse(sdpString: localSDP.sdp, creatorProvider: creatorProvider, localRole: localRole) else { completionHandler(.failure(ErrorCondition.not_acceptable)); return; } self.localSessionDescription = sdp; completionHandler(.success(sdp)); return; } completionHandler(.failure(error)); }); } func changeState(_ state: State) { self.state = state; self.delegate?.callStateChanged(self); } func switchCameraDevice() { #if targetEnvironment(simulator) #else if let localCapturer = self.localCapturer, let deviceID = self.localCameraDeviceID { let position = RTCCameraVideoCapturer.captureDevices().first(where: { $0.uniqueID == deviceID })?.position ?? .front; if let newCamera = RTCCameraVideoCapturer.captureDevices().first(where: { $0.position != position }), let format = RTCCameraVideoCapturer.format(for: newCamera, preferredOutputPixelFormat: localCapturer.preferredOutputPixelFormat()) { self.localCameraDeviceID = newCamera.uniqueID; localCapturer.startCapture(with: newCamera, format: format, fps: RTCCameraVideoCapturer.fps(for: format)); } } #endif } } protocol CallDelegate: AnyObject { func callDidStart(_ sender: Call); func callDidEnd(_ sender: Call); func callStateChanged(_ sender: Call); func call(_ sender: Call, didReceiveLocalVideoTrack localTrack: RTCVideoTrack); func call(_ sender: Call, didReceiveRemoteVideoTrack remoteTrack: RTCVideoTrack, forStream stream: String, fromReceiver: String); func call(_ sender: Call, goneRemoteVideoTrack remoteTrack: RTCVideoTrack, fromReceiver: String); } extension CallManager: PKPushRegistryDelegate { func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { let tokenString = pushCredentials.token.map { String(format: "%02.2hhx", $0) }.joined(); logger.info("received PKPush token: \(tokenString)"); PushEventHandler.instance.pushkitDeviceId = tokenString; } func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { // need to redesign that.. it is impossible to cancel a call via pushkit.. if let account = BareJID(payload.dictionaryPayload["account"] as? String) { logger.debug("voip push for account: \(account)"); if let encryped = payload.dictionaryPayload["encrypted"] as? String, let ivStr = payload.dictionaryPayload["iv"] as? String { if let key = NotificationEncryptionKeys.key(for: account), let data = Data(base64Encoded: encryped), let iv = Data(base64Encoded: ivStr) { logger.debug("got encrypted voip push with known key"); let cipher = Cipher.AES_GCM(); var decoded = Data(); if cipher.decrypt(iv: iv, key: key, encoded: data, auth: nil, output: &decoded) { logger.debug("got decrypted voip data: \(String(data: decoded, encoding: .utf8) as Any)"); if let payload = try? JSONDecoder().decode(VoIPPayload.self, from: decoded) { logger.debug("decoded voip payload successfully!"); if let sender = payload.sender, let client = XmppService.instance.getClient(for: account) { // we require `media` to be present (even empty) in incoming push for jingle session initiation if let media = payload.media { let session = JingleManager.instance.open(for: client, with: sender, sid: payload.sid, role: .responder, initiationType: .message); let call = Call(client: client, with: sender.bareJid, sid: payload.sid, direction: .incoming, media: media); self.reportIncomingCall(call, completionHandler: { result in switch result { case .success(_): break; case .failure(_): session.decline(); } completion(); }); } else { self.endCall(on: account, with: sender.bareJid, sid: payload.sid, completionHandler: { self.logger.debug("ended call"); }) } return; } } } } } } let uuid = UUID(); let update = CXCallUpdate(); update.remoteHandle = CXHandle(type: .generic, value: "Unknown"); provider.reportNewIncomingCall(with: uuid, update: update, completion: { error in if error == nil { self.provider.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded); } completion(); }) } class VoIPPayload: Decodable { public var sid: String; public var sender: JID?; public var media: [Call.Media]?; required public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self); sid = try container.decode(String.self, forKey: .sid) sender = try container.decodeIfPresent(JID.self, forKey: .sender); let media = try container.decodeIfPresent([String].self, forKey: .media); self.media = media?.map({ Call.Media.init(rawValue: $0)! }); // -- and so on... } public enum CodingKeys: String, CodingKey { case sid case sender case media } } } extension Call: RTCPeerConnectionDelegate { func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) { self.logger.debug("\(self), signaling state: \(stateChanged.rawValue)"); } func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) { } func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) { } func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) { self.logger.debug("\(self), negotiation required"); } func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { switch newState { case .disconnected: break; //self.reset(); case .connected: DispatchQueue.main.async { self.changeState(.connected); } default: break; } } func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) { } func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) { JingleManager.instance.dispatcher.async { self.localCandidates.append(candidate); self.sendLocalCandidates(); } } private func sendLocalCandidates() { guard let session = self.session else { return; } for candidate in localCandidates { self.sendLocalCandidate(candidate, session: session); } self.localCandidates = []; } private func sendLocalCandidate(_ candidate: RTCIceCandidate, session: JingleManager.Session) { guard let jingleCandidate = Jingle.Transport.ICEUDPTransport.Candidate(fromSDP: candidate.sdp) else { return; } guard let mid = candidate.sdpMid else { return; } guard let sdp = self.localSessionDescription else { return; } guard let content = sdp.contents.first(where: { c -> Bool in return c.name == mid; }), let transport = content.transports.first(where: {t -> Bool in return (t as? Jingle.Transport.ICEUDPTransport) != nil; }) as? Jingle.Transport.ICEUDPTransport else { return; } _ = session.transportInfo(contentName: mid, transport: Jingle.Transport.ICEUDPTransport(pwd: transport.pwd, ufrag: transport.ufrag, candidates: [jingleCandidate])); } func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) { } func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) { } func peerConnection(_ peerConnection: RTCPeerConnection, didAdd rtpReceiver: RTCRtpReceiver, streams mediaStreams: [RTCMediaStream]) { logger.debug("\(self), added receiver: \(rtpReceiver.receiverId)"); if let track = rtpReceiver.track as? RTCVideoTrack, let stream = mediaStreams.first { let mid = peerConnection.transceivers.first(where: { $0.receiver.receiverId == rtpReceiver.receiverId })?.mid; logger.debug("\(self), added video track: \(track), \(peerConnection.transceivers.map({ "[\($0.mid) - stopped: \($0.isStopped), \($0.receiver.receiverId), \($0.direction.rawValue)]" }).joined(separator: ", "))"); self.delegate?.call(self, didReceiveRemoteVideoTrack: track, forStream: mid ?? stream.streamId, fromReceiver: rtpReceiver.receiverId); } } func peerConnection(_ peerConnection: RTCPeerConnection, didRemove rtpReceiver: RTCRtpReceiver) { logger.debug("\(self), removed receiver: \(rtpReceiver.receiverId)"); if let track = rtpReceiver.track as? RTCVideoTrack { logger.debug("\(self), removed video track: \(track)"); self.delegate?.call(self, goneRemoteVideoTrack: track, fromReceiver: rtpReceiver.receiverId); } } func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) { if transceiver.direction == .recvOnly || transceiver.direction == .sendRecv { if transceiver.mediaType == .video { logger.debug("\(self), got video transceiver"); // guard let track = transceiver.receiver.track as? RTCVideoTrack else { // return; // } // self.delegate?.call(self, didReceiveRemoteVideoTrack: track) } } if transceiver.direction == .sendOnly || transceiver.direction == .sendRecv { if transceiver.mediaType == .video { guard let track = transceiver.sender.track as? RTCVideoTrack else { return; } self.delegate?.call(self, didReceiveLocalVideoTrack: track) } } } } extension Call { func sessionTerminated() { DispatchQueue.main.async { CallManager.instance?.endCall(self); } } func addRemoteCandidate(_ candidate: RTCIceCandidate) { DispatchQueue.main.async { guard let peerConnection = self.currentConnection else { return; } peerConnection.add(candidate); } } } ================================================ FILE: SiskinIM/voip/CameraPreviewView.swift ================================================ // // CameraPreviewView.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import AVFoundation import WebRTC class CameraPreviewView: UIView { override class var layerClass: AnyClass { return AVCaptureVideoPreviewLayer.self; } var viewAspectConstraint: NSLayoutConstraint?; var captureSession: AVCaptureSession? { didSet { DispatchQueue.main.async { let captureSession = self.captureSession; let previewLayer = self.previewLayer; RTCDispatcher.dispatchAsync(on: .typeCaptureSession) { previewLayer.session = captureSession; DispatchQueue.main.async { self.updateOrientation(); } } } } } var previewLayer: AVCaptureVideoPreviewLayer { return self.layer as! AVCaptureVideoPreviewLayer; } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder); addOrientationObserver(); } override init(frame: CGRect) { super.init(frame: frame); addOrientationObserver(); } deinit { NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: nil); } override func layoutSubviews() { super.layoutSubviews(); updateOrientation(); } fileprivate func addOrientationObserver() { NotificationCenter.default.addObserver(self, selector: #selector(orientationChanged), name: UIDevice.orientationDidChangeNotification, object: nil); } @objc func orientationChanged(_ notification: Notification) { updateOrientation(); } fileprivate func updateOrientation() { if previewLayer.connection?.isVideoOrientationSupported ?? false { switch UIDevice.current.orientation { case .portraitUpsideDown: previewLayer.connection!.videoOrientation = .portraitUpsideDown; case .landscapeRight: previewLayer.connection!.videoOrientation = .landscapeLeft; case .landscapeLeft: previewLayer.connection!.videoOrientation = .landscapeRight; case .portrait: previewLayer.connection!.videoOrientation = .portrait; default: previewLayer.connection!.videoOrientation = .portrait; } updateAspect(); } } func updateAspect() { if let oldConstraint = self.viewAspectConstraint { self.removeConstraint(oldConstraint); self.viewAspectConstraint = nil; } if let formatDescription = captureSession?.inputs.first?.ports.first?.formatDescription { let size = CMVideoFormatDescriptionGetDimensions(formatDescription); switch UIDevice.current.orientation { case .portraitUpsideDown, .portrait: self.viewAspectConstraint = self.widthAnchor.constraint(equalTo: self.heightAnchor, multiplier: CGFloat(size.height) / CGFloat(size.width)); default: self.viewAspectConstraint = self.widthAnchor.constraint(equalTo: self.heightAnchor, multiplier: CGFloat(size.width) / CGFloat(size.height)); } self.viewAspectConstraint?.isActive = true; } else { switch UIDevice.current.orientation { case .portraitUpsideDown, .portrait: self.viewAspectConstraint = self.widthAnchor.constraint(equalTo: self.heightAnchor, multiplier: CGFloat(16) / CGFloat(9)); default: self.viewAspectConstraint = self.widthAnchor.constraint(equalTo: self.heightAnchor, multiplier: CGFloat(9) / CGFloat(16)); } self.viewAspectConstraint?.isActive = true; } } func cameraChanged() { DispatchQueue.main.async { self.updateOrientation(); } } } ================================================ FILE: SiskinIM/voip/CreateMeetingViewController.swift ================================================ // // CreateMeetingViewController.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Combine import Martin class CreateMeetingViewController: MultiContactSelectionViewController { private let statusView = AccountStatusView(); private var changeAccountButton: UIBarButtonItem!; private var cancellables: Set = []; @Published fileprivate var client: XMPPClient?; @Published private var meetComponents: [MeetModule.MeetComponent] = []; override func viewDidLoad() { super.viewDidLoad(); changeAccountButton = UIBarButtonItem(barButtonSystemItem: .organize, target: self, action: #selector(changeAccount(_:))); changeAccountButton.tintColor = UIColor(named: "tintColor")!; self.toolbarItems = [changeAccountButton, statusView]; self.navigationController?.isToolbarHidden = false; navigationItem.leftBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Cancel", comment: "button label"), style: .plain, target: self, action: #selector(cancelTapped(_:))); navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Create", comment: "button label"), style: .done, target: self, action: #selector(createTapped(_:))); $selectedItems.combineLatest($meetComponents).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] items, components in self?.navigationItem.rightBarButtonItem?.isEnabled = (!items.isEmpty) && (!components.isEmpty); }).store(in: &cancellables); $client.receive(on: DispatchQueue.main).map({ $0?.userBareJid }).assign(to: \.account, on: statusView).store(in: &cancellables); $client.compactMap({ $0 }).sink(receiveValue: { [weak self] client in guard case .connected(_) = client.state else { DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Error", comment: "alert title"), message: NSLocalizedString("Default account is not connected. Please select a different account.", comment: "alert body"), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: { _ in self?.showChangeAccount(); })); alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: { _ in self?.dismiss(); })) self?.present(alert, animated: true, completion: nil); } return; } if let that = self { client.module(.meet).findMeetComponent(completionHandler: { result in switch result { case .success(let found): DispatchQueue.main.async { that.meetComponents = found; } case .failure(_): DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Error", comment: "alert title"), message: NSLocalizedString("Server of selected account does not provide support for hosting meetings. Please select a different account.", comment: "alert body"), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: { _ in that.showChangeAccount(); })); alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: { _ in that.dismiss(); })) that.present(alert, animated: true, completion: nil); } break; } }) } }).store(in: &cancellables); if let defAccount = AccountManager.defaultAccount, MeetEventHandler.instance.supportedAccounts.contains(defAccount) { client = XmppService.instance.getClient(for: defAccount); } } @objc func changeAccount(_ sender: Any) { self.showChangeAccount(); } private func showChangeAccount() { let selectAccountController = UIStoryboard(name: "VoIP", bundle: nil).instantiateViewController(withIdentifier: "SelectAccountController") as! SelectAccountController; selectAccountController.delegate = self; self.navigationController?.pushViewController(selectAccountController, animated: true); } @objc func createTapped(_ sender: Any) { guard let meetComponentJid = meetComponents.first?.jid, let client = self.client else { return; } let participants = self.selectedItems.map({ $0.jid }); guard !participants.isEmpty else { return; } client.module(.meet).createMeet(at: meetComponentJid, media: [.audio,.video], participants: participants, completionHandler: { result in switch result { case .success(let meetJid): DispatchQueue.main.async { self.dismiss(); DispatchQueue.main.async { guard let manager = CallManager.instance else { return; } manager.reportOutgoingCall(Meet(client: client, jid: meetJid.bareJid, sid: UUID().uuidString), completionHandler: { result in switch result { case .success(_): for jid in participants { client.module(.meet).sendMessageInitiation(action: .propose(id: UUID().uuidString, meetJid: meetJid, media: [.audio,.video]), to: JID(jid)); } case .failure(let error): DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Error", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("It was not possible to initiate a call: %@", comment: "alert body"), error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: { _ in self.dismiss(); })) self.present(alert, animated: true, completion: nil); } } }); } } case .failure(let error): DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Error", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("It was not possible to create a meeting. Server returned an error: %@", comment: "alert body"), error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: { _ in self.dismiss(); })) self.present(alert, animated: true, completion: nil); } break; } }) } @objc func cancelTapped(_ sender: Any) { dismiss(); } private func dismiss() { self.navigationController?.dismiss(animated: true, completion: nil); } class AccountStatusView: UIBarButtonItem { var account: BareJID? { didSet { let value = NSMutableAttributedString(string: "\(NSLocalizedString("Account", comment: "channel join status view label")): ", attributes: [.font: UIFont.preferredFont(forTextStyle: .footnote), .foregroundColor: UIColor.secondaryLabel]); value.append(NSAttributedString(string: account?.stringValue ?? NSLocalizedString("None", comment: "channel join status view label"), attributes: [.font: UIFont.preferredFont(forTextStyle: .footnote), .foregroundColor: UIColor(named: "tintColor")!])); accountLabel.attributedText = value; } } private var accountLabel: UILabel!; override init() { super.init(); setup(); } required init?(coder: NSCoder) { super.init(coder: coder); setup(); } func setup() { let view = UIView(); view.translatesAutoresizingMaskIntoConstraints = false; self.accountLabel = UILabel(); accountLabel.isUserInteractionEnabled = false; accountLabel.font = UIFont.systemFont(ofSize: UIFont.systemFontSize); accountLabel.translatesAutoresizingMaskIntoConstraints = false; view.addSubview(accountLabel); NSLayoutConstraint.activate([ view.topAnchor.constraint(equalTo: accountLabel.topAnchor), view.leadingAnchor.constraint(equalTo: accountLabel.leadingAnchor), view.trailingAnchor.constraint(greaterThanOrEqualTo: accountLabel.trailingAnchor), accountLabel.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) self.customView = view; self.account = nil; } } } class SelectAccountController: UITableViewController, UIPickerViewDataSource, UIPickerViewDelegate { @IBOutlet var accountField: UITextField!; weak var delegate: CreateMeetingViewController?; private let accountPicker = UIPickerView(); override func viewDidLoad() { super.viewDidLoad(); tableView.dataSource = self; } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); let accountPicker = UIPickerView(); accountPicker.dataSource = self; accountPicker.delegate = self; accountField.inputView = accountPicker; accountField.text = delegate?.client?.userBareJid.stringValue; } override func viewWillDisappear(_ animated: Bool) { if let account = BareJID(accountField!.text), let client = XmppService.instance.getClient(for: account) { delegate?.client = client; } super.viewWillDisappear(animated); } // override func numberOfSections(in tableView: UITableView) -> Int { // return 1; // } // // override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { // return "Account" // } // // override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { // return "Select account which should be used for meeting creation." // } func numberOfComponents(in pickerView: UIPickerView) -> Int { return 1; } func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { return AccountManager.getActiveAccounts().count; } func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { return AccountManager.getActiveAccounts()[row].name.stringValue; } func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { self.accountField.text = self.pickerView(pickerView, titleForRow: row, forComponent: component); } } ================================================ FILE: SiskinIM/voip/ExternalServiceDiscovery_Service_extension.swift ================================================ // // ExternalServiceDiscoveryModule_Service_extension.swift // // SiskinIM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Network import WebRTC import Martin extension ExternalServiceDiscoveryModule.Service { static let VALID_SERVICE_TYPES = ["stun", "stuns", "turn", "turns"]; func rtcIceServer() -> RTCIceServer? { guard ExternalServiceDiscoveryModule.Service.VALID_SERVICE_TYPES.contains(type) else { return nil; } guard !type.hasSuffix("s") || transport == .tcp else { return nil; } guard !type.hasPrefix("turn") || username != nil else { return nil; } let url = urlString(); return RTCIceServer(urlStrings: [url], username: username, credential: password, tlsCertPolicy: .insecureNoCheck); } private func urlString() -> String { let host = escapedHost(); if let port = self.port { if let transport = self.transport { return "\(type):\(host):\(port)?transport=\(transport.rawValue)" } else { return "\(type):\(host):\(port)" } } else { if let transport = self.transport { return "\(type):\(host)?transport=\(transport.rawValue)" } else { return "\(type):\(host)" } } } private func escapedHost() -> String { if #available(iOS 12.0, *) { return IPv6Address(self.host) != nil ? "[\(self.host)]" : self.host; } else { return self.host.contains(":") && !(self.host.hasPrefix("[") || self.host.hasSuffix("]")) ? "[\(self.host)]" : self.host; } } } ================================================ FILE: SiskinIM/voip/InviteToMeetingViewController.swift ================================================ // // InviteToMeetingViewController.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Combine import Martin class InviteToMeetingViewController: MultiContactSelectionViewController { private var changeAccountButton: UIBarButtonItem!; private var cancellables: Set = []; var meet: Meet?; override func viewDidLoad() { super.viewDidLoad(); navigationItem.leftBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Cancel", comment: "button label"), style: .plain, target: self, action: #selector(cancelTapped(_:))); navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Invite", comment: "button label"), style: .done, target: self, action: #selector(inviteTapped(_:))); $selectedItems.receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] items in self?.navigationItem.rightBarButtonItem?.isEnabled = (self?.meet != nil) && !items.isEmpty; }).store(in: &cancellables); } @objc func inviteTapped(_ sender: Any) { guard let meet = self.meet else { return; } let participants = self.selectedItems.map({ $0.jid }); guard !participants.isEmpty else { return; } meet.allow(jids: participants, completionHandler: { result in DispatchQueue.main.async { switch result { case .success(let jids): for jid in jids { meet.client.module(.meet).sendMessageInitiation(action: .propose(id: UUID().uuidString, meetJid: JID(meet.jid), media: [.audio, .video]), to: JID(jid)); } self.dismiss(); case .failure(let error): let alert = UIAlertController(title: NSLocalizedString("Error", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("It was not possible to grant selected users access to the meeting. Received an error: %@", comment: "alert body"), error.localizedDescription), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: { _ in self.dismiss(); })) self.present(alert, animated: true, completion: nil); } } }); } @objc func cancelTapped(_ sender: Any) { dismiss(); } private func dismiss() { self.navigationController?.dismiss(animated: true, completion: nil); } } ================================================ FILE: SiskinIM/voip/JingleManager.swift ================================================ // // JingleManager // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import WebRTC import Combine class JingleManager: JingleSessionManager { static let instance = JingleManager(); let connectionFactory: RTCPeerConnectionFactory; fileprivate var connections: [Session] = []; private var cancellables: Set = []; let dispatcher = QueueDispatcher(label: "jingleEventHandler"); init() { RTCInitializeSSL(); connectionFactory = RTCPeerConnectionFactory(encoderFactory: RTCDefaultVideoEncoderFactory(), decoderFactory: RTCDefaultVideoDecoderFactory()); } func session(for context: Context, with jid: JID, sid: String?) -> Session? { return session(for: context.userBareJid, with: jid, sid: sid); } func session(for account: BareJID, with jid: JID, sid: String?) -> Session? { return dispatcher.sync { return connections.first(where: {(sess) -> Bool in return sess.account == account && (sid == nil || sess.sid == sid) && (sess.jid == jid || (sess.jid.resource == nil && sess.jid.bareJid == jid.bareJid)); }); } } func open(for context: Context, with jid: JID, sid: String, role: Jingle.Content.Creator, initiationType: JingleSessionInitiationType) -> Session { return dispatcher.sync { let session = Session(context: context, jid: jid, sid: sid, role: role, initiationType: initiationType); self.connections.append(session); session.$state.removeDuplicates().sink(receiveValue: { [weak self, weak session] state in guard state == .terminated, let session = session else { return; } self?.close(session: session); }).store(in: &cancellables); return session; } } func close(for account: BareJID, with jid: JID, sid: String) -> Session? { return dispatcher.sync { guard let idx = self.connections.firstIndex(where: { sess -> Bool in return sess.sid == sid && sess.account == account && sess.jid == jid; }) else { return nil; } let session = self.connections.remove(at: idx); return session; } } func close(session: Session) { _ = self.close(for: session.account, with: session.jid, sid: session.sid); } enum ContentType { case audio case video case filetransfer } func support(for jid: JID, on account: BareJID) -> Set { guard let client = XmppService.instance.getClient(for: account) else { return []; } var features: [String] = []; if jid.resource == nil { PresenceStore.instance.presences(for: jid.bareJid, context: client).filter({ (p) -> Bool in return (p.type ?? .available) == .available; }).forEach({ (p) in guard let node = p.capsNode, let f = DBCapabilitiesCache.instance.getFeatures(for: node) else { return; } features.append(contentsOf: f); }) } else { guard let p = PresenceStore.instance.presence(for: jid, context: client), (p.type ?? .available) == .available, let node = p.capsNode, let f = DBCapabilitiesCache.instance.getFeatures(for: node) else { return []; } features.append(contentsOf: f); } var support: [ContentType] = []; // check jingle and supported transports... guard features.contains("urn:xmpp:jingle:1") && features.contains("urn:xmpp:jingle:transports:ice-udp:1") && features.contains("urn:xmpp:jingle:apps:dtls:0") && features.contains("urn:xmpp:jingle:apps:rtp:1") else { return Set(support); } if features.contains("urn:xmpp:jingle:apps:rtp:audio") { support.append(.audio); } if features.contains("urn:xmpp:jingle:apps:rtp:video") { support.append(.video); } if features.contains("urn:xmpp:jingle:apps:file-transfer:3") { support.append(.filetransfer); } return Set(support); } func messageInitiation(for context: Context, from jid: JID, action: Jingle.MessageInitiationAction) throws { switch action { case .propose(let id, let descriptions): if case .connected(_) = context.state { let pushModule = context.module(.push) as! SiskinPushNotificationsModule; guard (!context.module(.disco).accountDiscoResult.features.contains("tigase:push:jingle:0")) && pushModule.isEnabled else { return; } } guard self.session(for: context.userBareJid, with: jid, sid: id) == nil else { return; } let session = self.open(for: context, with: jid, sid: id, role: .responder, initiationType: .message); let media = descriptions.map({ Call.Media.from(string: $0.media) }).filter({ $0 != nil }).map({ $0! }); let call = Call(client: context as! XMPPClient, with: jid.bareJid, sid: id, direction: .incoming, media: media); guard let callManager = CallManager.instance else { throw XMPPError.feature_not_implemented; } callManager.reportIncomingCall(call, completionHandler: { result in switch result { case .success(_): // nothing to do as manager will call us back.. break; case .failure(_): session.decline(); } }); case .retract(let id): self.sessionTerminated(account: context.userBareJid, with: jid, sid: id); case .accept(let id): self.sessionTerminated(account: context.userBareJid, sid: id); case .reject(let id): self.sessionTerminated(account: context.userBareJid, sid: id); case .proceed(let id): guard let session = self.session(for: context, with: jid, sid: id) else { return; } session.accepted(by: jid); } } func sessionInitiated(for context: Context, with jid: JID, sid: String, contents: [Jingle.Content], bundle: [String]?) throws { guard CallManager.isAvailable, let content = contents.first, let _ = content.description as? Jingle.RTP.Description else { return; } let sdp = SDP(contents: contents, bundle: bundle); let media = sdp.contents.compactMap({ c -> Call.Media? in Call.Media.from(string: c.description?.media) }); let call = Call(client: context as! XMPPClient, with: jid.bareJid, sid: sid, direction: .incoming, media: media); if let session = session(for: context, with: jid, sid: sid) { session.initiated(contents: contents, bundle: bundle); } else { let session = open(for: context, with: jid, sid: sid, role: .responder, initiationType: .iq); session.initiated(contents: contents, bundle: bundle); guard let callManager = CallManager.instance else { throw XMPPError.feature_not_implemented; } callManager.reportIncomingCall(call, completionHandler: { result in switch result { case .success(_): break; case .failure(_): session.terminate(); } }) } } func sessionAccepted(for context: Context, with jid: JID, sid: String, contents: [Jingle.Content], bundle: [String]?) throws { guard let session = session(for: context, with: jid, sid: sid) else { throw XMPPError.item_not_found; } session.accepted(contents: contents, bundle: bundle); } func sessionTerminated(for context: Context, with jid: JID, sid: String) throws { sessionTerminated(account: context.userBareJid, with: jid, sid: sid); } private func sessionTerminated(account: BareJID, sid: String) { let toTerminate = dispatcher.sync(execute: { return connections.filter({(sess) -> Bool in return sess.account == account && sess.sid == sid; }); }); for session in toTerminate { session.terminated(); } } fileprivate func sessionTerminated(account: BareJID, with: JID, sid: String) { guard let session = session(for: account, with: with, sid: sid) else { return; } session.terminated(); } func transportInfo(for context: Context, with jid: JID, sid: String, contents: [Jingle.Content]) throws { guard let session = self.session(for: context, with: jid, sid: sid) else { throw XMPPError.item_not_found; } contents.forEach { (content) in content.transports.forEach({ (trans) in if let transport = trans as? Jingle.Transport.ICEUDPTransport { transport.candidates.forEach({ (candidate) in session.addCandidate(candidate, for: content.name); }) } }) } } func contentModified(for context: Context, with jid: JID, sid: String, action: Jingle.ContentAction, contents: [Jingle.Content], bundle: [String]?) throws { guard let session = self.session(for: context, with: jid, sid: sid) else { throw XMPPError.item_not_found; } session.contentModified(action: action, contents: contents, bundle: bundle); } func sessionInfo(for context: Context, with jid: JID, sid: String, info: [Jingle.SessionInfo]) throws { guard let session = self.session(for: context, with: jid, sid: sid) else { throw XMPPError.item_not_found; } session.sessionInfoReceived(info: info); } } extension JingleManager { func session(forCall call: Call) -> Session? { return dispatcher.sync { return self.connections.first(where: { $0.account == call.account && $0.jid.bareJid == call.jid && $0.sid == call.sid }); } } } extension String { static func randomString(length: Int) -> String { let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; return String((0...length-1).map{ _ in letters.randomElement()! }); } } ================================================ FILE: SiskinIM/voip/JingleManager_Session.swift ================================================ // // JingleManager_Session.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Martin import WebRTC import Combine protocol JingleSessionActionDelegate: AnyObject { func received(action: JingleManager.Session.Action); } extension JingleManager { class Session: JingleSession { private static let queue = DispatchQueue(label: "JingleSessionQueue"); private weak var delegate: JingleSessionActionDelegate?; private var actionsQueue: [Action] = []; public enum Action { case contentSet(SDP) case contentApply(Jingle.ContentAction, SDP) case transportAdd(Jingle.Transport.ICEUDPTransport.Candidate, String); case sessionInfo([Jingle.SessionInfo]) var order: Int { switch self { case .contentSet(_): return 0; case .contentApply(_,_): return 0; case .transportAdd(_, _): return 1; case .sessionInfo(_): return 2; } } } override init(context: Context, jid: JID, sid: String, role: Jingle.Content.Creator, initiationType: JingleSessionInitiationType) { super.init(context: context, jid: jid, sid: sid, role: role, initiationType: initiationType); } override func initiated(contents: [Jingle.Content], bundle: [String]?) { super.initiated(contents: contents, bundle: bundle) self.received(action: .contentSet(SDP(contents: contents, bundle: bundle))); } private func received(action: Action) { Session.queue.async { if self.delegate == nil { if let idx = self.actionsQueue.firstIndex(where: { $0.order > action.order }) { self.actionsQueue.insert(action, at: idx); } else { self.actionsQueue.append(action); } } else { self.delegate?.received(action: action); } } } public func setDelegate(_ delegate: JingleSessionActionDelegate) { Session.queue.async { self.delegate = delegate; for action in self.actionsQueue { self.delegate?.received(action: action); } self.actionsQueue.removeAll(); } } override func accept() { super.accept(); } override func accepted(contents: [Jingle.Content], bundle: [String]?) { super.accepted(contents: contents, bundle: bundle) received(action: .contentSet(SDP(contents: contents, bundle: bundle))); } func decline() { self.terminate(reason: .decline); } open override func contentModified(action: Jingle.ContentAction, contents: [Jingle.Content], bundle: [String]?) { let sdp = SDP(contents: contents, bundle: bundle); received(action: .contentApply(action, sdp)); } open override func sessionInfoReceived(info: [Jingle.SessionInfo]) { received(action: .sessionInfo(info)); } func addCandidate(_ candidate: Jingle.Transport.ICEUDPTransport.Candidate, for contentName: String) { received(action: .transportAdd(candidate, contentName)); } } } extension JingleSessionState { var rawValue: String { switch self { case .accepted: return "accepted"; case .created: return "created"; case .initiating: return "initiating"; case .terminated: return "terminated"; } } } ================================================ FILE: SiskinIM/voip/MeetController.swift ================================================ // // MeetController.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import WebRTC import Combine import Martin import TigaseLogging class MeetController: UIViewController, UICollectionViewDataSource, RTCVideoViewDelegate, CallDelegate { func callDidStart(_ sender: Call) { // nothing to do.. } func callDidEnd(_ sender: Call) { DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Meeting ended", comment: "alert title"), message: NSLocalizedString("Meeting has ended", comment: "alert body"), preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: { _ in self.endCall(self); })); self.present(alert, animated: true, completion: nil); } } func callStateChanged(_ sender: Call) { // nothing to do.. } func call(_ sender: Call, didReceiveLocalVideoTrack localTrack: RTCVideoTrack) { DispatchQueue.main.async { localTrack.add(self.localVideoRenderer); } } func call(_ sender: Call, didReceiveRemoteVideoTrack remoteTrack: RTCVideoTrack, forStream mid: String, fromReceiver receiverId: String) { DispatchQueue.main.async { self.items.append(Item(mid: mid, videoTrack: remoteTrack, receiverId: receiverId)); self.collectionView.performBatchUpdates({ self.collectionView.insertItems(at: [IndexPath(item: self.items.count - 1, section: 0)]); }, completion: nil); } } func call(_ sender: Call, goneRemoteVideoTrack remoteTrack: RTCVideoTrack, fromReceiver receiverId: String) { DispatchQueue.main.async { if let idx = self.items.firstIndex(where: { $0.receiverId == receiverId }) { self.items.remove(at: idx); self.collectionView.performBatchUpdates({ self.collectionView.deleteItems(at: [IndexPath(item: idx, section: 0)]); }, completion: nil); } } } func call(_ sender: Call, goneLocalVideoTrack localTrack: RTCVideoTrack) { DispatchQueue.main.async { localTrack.remove(self.localVideoRenderer); } } private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "meet") private let collectionView = UICollectionView(frame: .zero, collectionViewLayout: FlowLayout()); private let collectonViewDelegate = CollectionViewDelegate(); private let buttonsStack = UIStackView(frame: .zero); private var endCallButton: RoundButton?; private var muteButton: RoundButton?; private var moreButton: RoundButton?; // #if targetEnvironment(simulator) // private let localVideoRenderer = RTCEAGLVideoView(); // #else private let localVideoRenderer = RTCMTLVideoView(); // #endif private var localVideoRendererWidth: NSLayoutConstraint?; private var cancellables: Set = []; @Published private var publisherByMid: [String: MeetModule.Publisher] = [:]; private var remove: Bool = true; private var items: [Item] = []; private var audioSession: AudioSesion?; private var meet: Meet? { didSet { meet?.$outgoingCall.sink(receiveValue: { [weak self] call in guard let that = self else { return; } call?.delegate = that; }).store(in: &cancellables); meet?.$incomingCall.sink(receiveValue: { [weak self] call in guard let that = self else { return; } call?.delegate = that; }).store(in: &cancellables); meet?.$publishers.sink(receiveValue: { [weak self] publishers in var dict: [String: MeetModule.Publisher] = [:]; for publisher in publishers { for stream in publisher.streams { dict[stream] = publisher; } } self?.publisherByMid = dict; }).store(in: &cancellables); if meet != nil { self.audioSession = AudioSesion(preferSpeaker: true); } } } private var muted: Bool = false { didSet { muteButton?.tintColor = muted ? UIColor.red : UIColor.white; } } public static func open(meet: Meet) { DispatchQueue.main.async { var topController = UIApplication.shared.windows.first(where: { $0.isKeyWindow })?.rootViewController; while (topController?.presentedViewController != nil) { topController = topController?.presentedViewController; } let controller = MeetController(); controller.meet = meet; controller.modalPresentationStyle = .fullScreen; controller.modalTransitionStyle = .coverVertical; (topController?.presentingViewController ?? topController)?.present(controller, animated: true, completion: nil);//(controller, animated: true, completion: nil); } } func numberOfSections(in collectionView: UICollectionView) -> Int { return 1; } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return items.count; } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "VideoStreamCell", for: indexPath); if let view = cell as? VideoStreamCell, let account = meet?.client.userBareJid { view.delegate = self; view.set(item: items[indexPath.item], account: account, publishersPublisher: $publisherByMid); } return cell; } override func loadView() { // let view = UIView(); super.loadView(); view.isOpaque = true; // let flowLayout = FlowLayout(); (collectionView.collectionViewLayout as? FlowLayout)?.scrollDirection = .vertical; (collectionView.collectionViewLayout as? FlowLayout)?.itemSize = CGSize(width: 100, height: 100); (collectionView.collectionViewLayout as? FlowLayout)?.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) // collectionView.collectionViewLayout = flowLayout; collectionView.delegate = collectonViewDelegate; collectionView.translatesAutoresizingMaskIntoConstraints = false; collectionView.allowsSelection = false; collectionView.autoresizesSubviews = true; view.addSubview(collectionView); buttonsStack.axis = .horizontal; buttonsStack.spacing = 40; buttonsStack.distribution = .equalSpacing; buttonsStack.translatesAutoresizingMaskIntoConstraints = false; buttonsStack.setContentHuggingPriority(.defaultHigh, for: .horizontal); buttonsStack.setContentHuggingPriority(.defaultHigh, for: .vertical); localVideoRenderer.translatesAutoresizingMaskIntoConstraints = false; localVideoRenderer.layer.cornerRadius = 5; localVideoRenderer.layer.backgroundColor = UIColor.red.cgColor; localVideoRenderer.layer.masksToBounds = true; view.addSubview(localVideoRenderer); endCallButton = RoundButton(); endCallButton?.setImage(UIImage(systemName: "xmark"), for: .normal); endCallButton?.addTarget(self, action: #selector(endCall(_:)), for: .touchUpInside); // endCallButton?.hasBorder = false; endCallButton?.backgroundColor = UIColor.systemRed; endCallButton?.tintColor = UIColor.white; buttonsStack.addArrangedSubview(endCallButton!) muteButton = RoundButton(); muteButton?.setImage(UIImage(systemName: "mic.slash.fill"), for: .normal); muteButton?.addTarget(self, action: #selector(muteClicked(_:)), for: .touchUpInside); // muteButton?.hasBorder = false; muteButton?.backgroundColor = UIColor.white.withAlphaComponent(0.1); muteButton?.tintColor = UIColor.white; buttonsStack.addArrangedSubview(muteButton!); moreButton = RoundButton(); moreButton?.setImage(UIImage(systemName: "ellipsis"), for: .normal); // inviteButton?.hasBorder = false; moreButton?.backgroundColor = UIColor.white.withAlphaComponent(0.1); moreButton?.tintColor = UIColor.white; buttonsStack.addArrangedSubview(moreButton!); if #available(iOS 14.0, *) { moreButton?.menu = UIMenu(title: "", children: [ UIAction(title: NSLocalizedString("Invite…", comment: "button label"), image: UIImage(systemName: "person.fill.badge.plus"), handler: { action in self.inviteToCallClicked(action); }), UIAction(title: NSLocalizedString("Switch camera", comment: "button label"), image: UIImage(systemName: "arrow.triangle.2.circlepath.camera.fill"), handler: { action in self.switchCamera(); }), UIMenu(title: NSLocalizedString("Switch audio", comment: "button label"), image: UIImage(systemName: "speaker.wave.2"), children: [ switchAudioActions() ]) ].reversed()); moreButton?.showsMenuAsPrimaryAction = true; } else { moreButton?.addTarget(self, action: #selector(moreTapped(_:)), for: .touchUpInside); } view.addSubview(buttonsStack); localVideoRendererWidth = localVideoRenderer.widthAnchor.constraint(equalToConstant: 80); NSLayoutConstraint.activate([ localVideoRendererWidth!, localVideoRenderer.heightAnchor.constraint(equalToConstant: 80), endCallButton!.widthAnchor.constraint(equalTo: endCallButton!.heightAnchor), endCallButton!.widthAnchor.constraint(equalToConstant: 40), muteButton!.widthAnchor.constraint(equalTo: muteButton!.heightAnchor), muteButton!.widthAnchor.constraint(equalToConstant: 40), moreButton!.widthAnchor.constraint(equalTo: moreButton!.heightAnchor), moreButton!.widthAnchor.constraint(equalToConstant: 40), view.leadingAnchor.constraint(equalTo: collectionView.leadingAnchor, constant: 0), view.trailingAnchor.constraint(equalTo: collectionView.trailingAnchor, constant: 0), view.layoutMarginsGuide.topAnchor.constraint(equalTo: collectionView.topAnchor, constant: 0), buttonsStack.topAnchor.constraint(lessThanOrEqualTo: collectionView.bottomAnchor, constant: 0), //buttonsStack.centerXAnchor.constraint(equalTo: view.centerXAnchor), //buttonsStack.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor), buttonsStack.leadingAnchor.constraint(greaterThanOrEqualTo: localVideoRenderer.trailingAnchor, constant: 20), buttonsStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), view.bottomAnchor.constraint(greaterThanOrEqualTo: buttonsStack.bottomAnchor, constant: 10), buttonsStack.centerYAnchor.constraint(equalTo: localVideoRenderer.centerYAnchor), localVideoRenderer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), localVideoRenderer.topAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 10), view.bottomAnchor.constraint(equalTo: localVideoRenderer.bottomAnchor, constant: 10) ]) localVideoRenderer.delegate = self; } override func viewDidLoad() { super.viewDidLoad(); collectionView.register(VideoStreamCell.self, forCellWithReuseIdentifier: "VideoStreamCell"); collectionView.dataSource = self; } @objc func endCall(_ sender: Any) { if let meet = self.meet { CallManager.instance?.endCall(meet); } self.dismiss(animated: true, completion: nil); } @objc func muteClicked(_ sender: Any) { muted = !muted; meet?.muted(value: muted); } @objc func inviteToCallClicked(_ sender: Any) { guard let meet = self.meet else { return; } let selector = InviteToMeetingViewController(style: .plain); selector.meet = meet; let navController = UINavigationController(rootViewController: selector); self.present(navController, animated: true, completion: nil); } @objc func moreTapped(_ sender: UIButton) { let controller = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet); controller.popoverPresentationController?.sourceView = sender; controller.popoverPresentationController?.sourceRect = sender.bounds; controller.addAction(UIAlertAction(title: NSLocalizedString("Invite…", comment: "button label"), style: .default, handler: { action in self.inviteToCallClicked(sender); })) controller.addAction(UIAlertAction(title: NSLocalizedString("Switch camera", comment: "button label"), style: .default, handler: { action in self.switchCamera(); })); controller.addAction(UIAlertAction(title: NSLocalizedString("Switch audio", comment: "button label"), style: .default, handler: { action in DispatchQueue.main.async { self.switchAudio(sender); } })) controller.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); self.present(controller, animated: true, completion: nil); } func switchCamera() { self.meet?.switchCameraDevice(); } func switchAudio(_ sender: UIButton) { let controller = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet); for audioPort in audioSession?.availableAudioPorts() ?? [] { let action = UIAlertAction(title: audioPort.label, style: .default, handler: { action in self.audioSession?.set(outputMode: audioPort); }); switch audioPort { case .automatic: break; case .builtin: action.setValue(AVAudioSession.sharedInstance().currentRoute.outputs.contains(where: { $0.portType == .builtInReceiver }), forKey: "checked") case .speaker: action.setValue(AVAudioSession.sharedInstance().currentRoute.outputs.contains(where: { $0.portType == .builtInSpeaker }), forKey: "checked") case .custom(let port): action.setValue(AVAudioSession.sharedInstance().currentRoute.inputs.contains(where: { $0.portType == port.portType }), forKey: "checked"); } if let image = audioPort.icon { action.setValue(image.scaled(maxWidthOrHeight: 30, isOpaque: false), forKey: "image"); } controller.addAction(action) } controller.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); controller.popoverPresentationController?.sourceView = sender; controller.popoverPresentationController?.sourceRect = sender.bounds; self.present(controller, animated: true, completion: nil); } @available(iOS 14.0, *) func switchAudioActions() -> UIDeferredMenuElement { return UIDeferredMenuElement({ [weak self] completion in var items: [UIMenuElement] = []; for audioPort in self?.audioSession?.availableAudioPorts() ?? [] { var selected = false; switch audioPort { case .automatic: break; case .builtin: selected = AVAudioSession.sharedInstance().currentRoute.outputs.contains(where: { $0.portType == .builtInReceiver }); case .speaker: selected = AVAudioSession.sharedInstance().currentRoute.outputs.contains(where: { $0.portType == .builtInSpeaker }); case .custom(let port): selected = AVAudioSession.sharedInstance().currentRoute.inputs.contains(where: { $0.portType == port.portType }); } let item = UIAction(title: audioPort.label, image: audioPort.icon, state: selected ? .on : .off, handler: { action in self?.audioSession?.set(outputMode: audioPort); }); items.append(item); } completion(items.reversed()); }) } func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) { DispatchQueue.main.async { self.localVideoRendererWidth?.constant = (size.width * self.localVideoRenderer.frame.height) / size.height; } } private class FlowLayout: UICollectionViewFlowLayout { override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true; } override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext { let context = super.invalidationContext(forBoundsChange: newBounds) as! UICollectionViewFlowLayoutInvalidationContext; context.invalidateFlowLayoutDelegateMetrics = true; return context; } } private class CollectionViewDelegate: NSObject, UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { let itemsCount = collectionView.numberOfItems(inSection: indexPath.section); let spacing = (collectionViewLayout as! UICollectionViewFlowLayout).minimumLineSpacing; guard itemsCount > 1 else { return collectionView.frame.size; } let ratio = collectionView.frame.size.width / collectionView.frame.size.height; switch itemsCount { case 2: if ratio > 1 { return CGSize(width: collectionView.frame.size.width / 2 - spacing, height: collectionView.frame.size.height); } else { return CGSize(width: collectionView.frame.size.width, height: collectionView.frame.size.height / 2 - spacing); } case 3: if ratio > 2 { return CGSize(width: collectionView.frame.size.width, height: collectionView.frame.size.height / 3 - spacing); } else { return CGSize(width: indexPath.item == 2 ? collectionView.frame.size.width : (collectionView.frame.size.width / 2 - spacing), height: collectionView.frame.size.height / 2 - spacing); } case 4: return CGSize(width: collectionView.frame.size.width / 2 - spacing, height: collectionView.frame.size.height / 2 - spacing) case 5: if ratio > 1 { let fullRow = indexPath.item < 3; return CGSize(width: fullRow ? (collectionView.frame.size.width / 3 - spacing) : (collectionView.frame.size.width / 2 - spacing), height: collectionView.frame.size.height / 2 - spacing) } else { let fullRow = indexPath.item < 4; return CGSize(width: fullRow ? (collectionView.frame.size.width / 2 - spacing) : (collectionView.frame.size.width), height: collectionView.frame.size.height / 3 - spacing); } case 6: if ratio > 1 { return CGSize(width: collectionView.frame.size.width / 3 - spacing, height: collectionView.frame.size.height / 2 - spacing); } else { return CGSize(width: collectionView.frame.size.width / 2 - spacing, height: collectionView.frame.size.height / 3 - spacing); } default: break; } guard itemsCount > 0 else { return collectionView.frame.size; } let columns = ceil(sqrt(CGFloat(itemsCount) * ratio)); let itemAreaSize = (collectionView.frame.size.width / columns) - (collectionViewLayout as! UICollectionViewFlowLayout).minimumLineSpacing; return CGSize(width: itemAreaSize, height: itemAreaSize); } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let controller = (collectionView.dataSource as? MeetController) else { return nil; } let item = controller.items[indexPath.item]; guard let publisher = controller.publisherByMid[item.mid] else { return nil; } return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { suggestedActions in let kickOutAction = UIAction(title: NSLocalizedString("Kick out", comment: "button label"), image: nil, attributes: .destructive, handler: { action in controller.meet?.deny(jids: [publisher.jid], completionHandler: { result in }) }); return UIMenu(title: "", children: [kickOutAction]); }) } } private struct Item { let mid: String; let videoTrack: RTCVideoTrack?; let receiverId: String; } private class VideoStreamCell: UICollectionViewCell, RTCVideoViewDelegate { weak var delegate: MeetController?; private let avatarView: AvatarView = AvatarView(); private let nameLabel: UILabel = UILabel(); private let nameBox = UIView(); // #if targetEnvironment(simulator) // private let videoRenderer = RTCEAGLVideoView(frame: .zero); // #else private let videoRenderer = RTCMTLVideoView(frame: .zero); // #endif private var cancellables: Set = []; private var avatarSize: NSLayoutConstraint?; private var videoTrack: RTCVideoTrack? { willSet { videoTrack?.remove(videoRenderer); } didSet { videoTrack?.add(videoRenderer); } } private var contact: Contact? { didSet { cancellables.removeAll(); if let contact = contact { contact.$displayName.map({ $0 as String? }).receive(on: DispatchQueue.main).assign(to: \.text, on: nameLabel).store(in: &cancellables); contact.avatarPublisher.combineLatest(contact.$displayName).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] avatar, name in self?.avatarView.set(name: name, avatar: avatar) }).store(in: &cancellables); } } } private var item: Item?; private var publisherCancellable: AnyCancellable? { willSet { publisherCancellable?.cancel(); } } func set(item: Item, account: BareJID, publishersPublisher: Published<[String:MeetModule.Publisher]>.Publisher) { self.videoTrack = item.videoTrack; self.item = item; publisherCancellable = publishersPublisher.map({ $0[item.mid]?.jid }).removeDuplicates().map({ j -> Contact? in if let jid = j { return ContactManager.instance.contact(for: .init(account: account, jid: jid, type: .buddy)); } return nil; }).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] contact in self?.contact = contact; }); } override init(frame: CGRect) { super.init(frame: frame); nameLabel.text = ""; nameLabel.numberOfLines = 0; backgroundColor = UIColor.systemGray; layer.masksToBounds = true; layer.cornerRadius = 10; nameBox.translatesAutoresizingMaskIntoConstraints = false; nameBox.setContentCompressionResistancePriority(.defaultLow, for: .horizontal); nameBox.setContentCompressionResistancePriority(.defaultHigh, for: .vertical); avatarView.translatesAutoresizingMaskIntoConstraints = false; nameLabel.translatesAutoresizingMaskIntoConstraints = false; videoRenderer.translatesAutoresizingMaskIntoConstraints = false; #if targetEnvironment(simulator) videoRenderer.delegate = self; #else videoRenderer.videoContentMode = .scaleAspectFill; #endif nameLabel.textAlignment = .center; nameBox.backgroundColor = UIColor.darkGray.withAlphaComponent(0.9); nameLabel.font = UIFont.boldSystemFont(ofSize: UIFont.systemFontSize) //nameLabel.drawsBackground = true; addSubview(avatarView); addSubview(videoRenderer); nameBox.addSubview(nameLabel); addSubview(nameBox); avatarSize = avatarView.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 0.65); avatarView.layer.masksToBounds = true; #if targetEnvironment(simulator) let videoRendererConstraints: [NSLayoutConstraint] = []; #else let videoRendererConstraints: [NSLayoutConstraint] = [ self.leadingAnchor.constraint(equalTo: videoRenderer.leadingAnchor), self.topAnchor.constraint(equalTo: videoRenderer.topAnchor), self.trailingAnchor.constraint(equalTo: videoRenderer.trailingAnchor), self.bottomAnchor.constraint(equalTo: videoRenderer.bottomAnchor) ] #endif NSLayoutConstraint.activate([ avatarView.heightAnchor.constraint(equalTo: avatarView.widthAnchor), self.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor), self.centerXAnchor.constraint(equalTo: avatarView.centerXAnchor), avatarSize!, nameBox.centerXAnchor.constraint(equalTo: nameLabel.centerXAnchor), nameBox.leadingAnchor.constraint(lessThanOrEqualTo: nameLabel.leadingAnchor), nameBox.topAnchor.constraint(equalTo: nameLabel.topAnchor, constant: -6), nameBox.bottomAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 6), self.leadingAnchor.constraint(equalTo: nameBox.leadingAnchor), self.trailingAnchor.constraint(equalTo: nameBox.trailingAnchor), self.bottomAnchor.constraint(equalTo: nameBox.bottomAnchor) ] + videoRendererConstraints) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func removeFromSuperview() { self.videoTrack?.remove(videoRenderer); } private var videoSize: CGSize = .zero; func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize videoSize: CGSize) { #if targetEnvironment(simulator) self.videoSize = videoSize; self.setNeedsLayout(); #endif } #if targetEnvironment(simulator) override func layoutSubviews() { super.layoutSubviews(); guard videoSize.width > 0 && videoSize.height > 0 else { return; } var newFrame: CGRect = self.bounds; if videoSize.width > videoSize.height { newFrame.size = CGSize(width: (videoSize.width / videoSize.height) * newFrame.height, height: newFrame.height); } else { newFrame.size = CGSize(width: newFrame.width, height: (videoSize.height / videoSize.width) * newFrame.width); } videoRenderer.frame = newFrame; videoRenderer.center = CGPoint(x: bounds.midX, y: bounds.midY) } #endif } } ================================================ FILE: SiskinIM/voip/MeetManager.swift ================================================ // // MeetController.swift // // Siskin IM // Copyright (C) 2021 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Combine import Martin import TigaseLogging import CallKit class Meet: CallBase { private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "meet") private static let dispatcher = QueueDispatcher(label: "MeetDispatcher"); let client: XMPPClient; var account: BareJID { return client.userBareJid; } let jid: BareJID var sid: String let uuid = UUID(); var name: String { return jid.stringValue; } var remoteHandle: CXHandle { return CXHandle(type: .generic, value: jid.stringValue); } let media: [Call.Media] = [.audio,.video] var description: String { return "Meet[on: \(client.userBareJid), with: \(jid), sid: \(sid), id: \(uuid)]"; } func isEqual(_ call: CallBase) -> Bool { guard let meet = call as? Meet else { return false; } return meet.account == account && meet.jid == jid && meet.sid == sid; } func reset() { leave(); } func start(completionHandler: @escaping (Result) -> Void) { join(completionHandler: completionHandler); } func accept(offerMedia: [Call.Media], completionHandler: @escaping (Result) -> Void) { join(completionHandler: completionHandler); } func ringing() { // nothing to do for now.. } func end() { reset(); } func mute(value: Bool) { muted(value: value); } init(client: XMPPClient, jid: BareJID, sid: String) { self.client = client; self.jid = jid; self.sid = sid; } @Published fileprivate(set) var outgoingCall: Call?; @Published fileprivate(set) var incomingCall: Call?; @Published fileprivate(set) var publishers: [MeetModule.Publisher] = []; private var presenceSent = false; private var cancellables: Set = []; private func join(completionHandler: ((Result)->Void)?) { let call = Call(client: client, with: jid, sid: UUID().uuidString, direction: .outgoing, media: [.audio, .video]); call.ringing(); if !PresenceStore.instance.isAvailable(for: jid, context: client) { let presence = Presence(); presence.to = JID(jid); client.writer.write(presence); presenceSent = true; } client.module(.meet).eventsPublisher.receive(on: Meet.dispatcher.queue).filter({ $0.meetJid == self.jid }).sink(receiveValue: { [weak self] event in self?.handle(event: event); }).store(in: &cancellables); PresenceStore.instance.bestPresenceEvents.filter({ $0.jid == self.jid && ($0.presence == nil || $0.presence?.type == .unavailable) }).sink(receiveValue: { _ in call.reset(); }).store(in: &cancellables); MeetController.open(meet: self); self.outgoingCall = call; call.initiateOutgoingCall(with: JID(jid), completionHandler: { result in switch result { case .success(_): self.logger.info("initiated outgoing call of a meet \(self.jid)") break; case .failure(let error): self.logger.info("initiation of outgoing call of a meet \(self.jid) failed with \(error)") call.reset(); self.cancellables.removeAll(); } completionHandler?(result); }) } public func allow(jids: [BareJID], completionHandler: @escaping (Result<[BareJID],XMPPError>)->Void) { client.module(.meet).allow(jids: jids, in: JID(jid), completionHandler: { result in completionHandler(result.map({ _ in jids })); }); } public func deny(jids: [BareJID], completionHandler: @escaping (Result<[BareJID],XMPPError>)->Void) { client.module(.meet).deny(jids: jids, in: JID(jid), completionHandler: { result in completionHandler(result.map({ _ in jids })); }); } private func leave() { cancellables.removeAll(); outgoingCall?.reset(); incomingCall?.reset(); if presenceSent { let presence = Presence(); presence.type = .unavailable; presence.to = JID(jid); client.writer.write(presence); } } public func muted(value: Bool) { outgoingCall?.mute(value: value); } public func switchCameraDevice() { outgoingCall?.switchCameraDevice(); } func setIncomingCall(_ call: Call) { incomingCall = call; call.accept(offerMedia: []); } private func handle(event: MeetModule.MeetEvent) { switch event { case .publisherJoined(_, let publisher): publishers.append(publisher); case .publisherLeft(_, let publisher): publishers = publishers.filter({ $0.jid != publisher.jid }) case .inivitation(_, _): break; } } } ================================================ FILE: SiskinIM/voip/RTCCameraVideoCapturer_Format.swift ================================================ // // RTCCameraVideoCapturer_Format.swift // // Siskin IM // Copyright (C) 2020 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import WebRTC extension RTCCameraVideoCapturer { static func format(for device: AVCaptureDevice, preferredWidth: Int32 = Int32.max, preferredHeight: Int32 = Int32.max, preferredOutputPixelFormat: FourCharCode) -> AVCaptureDevice.Format? { var formats = RTCCameraVideoCapturer.supportedFormats(for: device); // trimm to fit in 720x480 // formats = formats.filter({ format -> Bool in // let size = CMVideoFormatDescriptionGetDimensions(format.formatDescription); // return !(max(size.width, size.height) > 720 || min(size.width, size.height) > 480); // }); formats = formats.sorted(by: { f1, f2 -> Bool in let size1 = CMVideoFormatDescriptionGetDimensions(f1.formatDescription); let size2 = CMVideoFormatDescriptionGetDimensions(f2.formatDescription); let diff1 = Int(abs(preferredWidth - size1.width)) + Int(abs(preferredHeight - size1.height)); let diff2 = Int(abs(preferredWidth - size2.width)) + Int(abs(preferredHeight - size2.height)); if diff1 == diff2 { if CMFormatDescriptionGetMediaSubType(f1.formatDescription) == preferredOutputPixelFormat { return true; } return false; } return diff1 < diff2; }); return formats.first; } static func fps(for format: AVCaptureDevice.Format) -> Int { let limit = 30.0; var fps = 0.0; for range in format.videoSupportedFrameRateRanges { fps = max(fps, range.maxFrameRate); } return Int(min(fps, limit)); } } ================================================ FILE: SiskinIM/voip/VideoCallController.swift ================================================ // // VideoCallController.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import WebRTC import Martin import UserNotifications import os //import CallKit public class VideoCallController: UIViewController, RTCVideoViewDelegate, CallDelegate { private var call: Call?; fileprivate var localVideoTrack: RTCVideoTrack? { willSet { if localVideoTrack != nil && localVideoView != nil { localVideoTrack!.remove(localVideoView!); } } didSet { if localVideoTrack != nil && localVideoView != nil { localVideoTrack!.add(localVideoView!); } } } fileprivate var remoteVideoTrack: RTCVideoTrack? { willSet { if remoteVideoTrack != nil && remoteVideoView != nil { remoteVideoTrack!.remove(remoteVideoView); self.updateAvatarVisibility(); } } didSet { if remoteVideoTrack != nil && remoteVideoView != nil { remoteVideoTrack!.add(remoteVideoView); self.updateAvatarVisibility(); } } } public func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) { // lets do not do anything for now... // DispatchQueue.main.async { // if videoView === self.localVideoView! { // self.lo // } // } } func callDidStart(_ sender: Call) { self.call = sender; self.audioSession = AudioSesion(preferSpeaker: true) self.updateAvatarView(); self.updateStateLabel(); } func callDidEnd(_ sender: Call) { self.call = nil; self.dismiss(animated: true, completion: nil); } func callStateChanged(_ sender: Call) { self.updateStateLabel(); } func call(_ sender: Call, didReceiveLocalVideoTrack localTrack: RTCVideoTrack) { DispatchQueue.main.async { self.localVideoTrack = localTrack; } } func call(_ sender: Call, didReceiveRemoteVideoTrack remoteTrack: RTCVideoTrack, forStream: String, fromReceiver: String) { DispatchQueue.main.async { self.remoteVideoTrack = remoteTrack; } } func call(_ sender: Call, goneRemoteVideoTrack remoteTrack: RTCVideoTrack, fromReceiver: String) { } // #if targetEnvironment(simulator) // func callDidStart(_ sender: CallManager) { // } // func callDidEnd(_ sender: CallManager) { // } // func callManager(_ sender: CallManager, didReceiveRemoteVideoTrack remoteTrack: RTCVideoTrack) { // } // func callManager(_ sender: CallManager, didReceiveLocalVideoCapturer localCapturer: RTCCameraVideoCapturer) { // } // func callStateChanged(_ sender: CallManager) { // } // #else static func checkMediaAvailability(forCall call: Call, completionHandler: @escaping (Result)->Void) { var errors: Bool = false; let group = DispatchGroup(); group.enter(); for media in call.media { group.enter(); self.checkAccesssPermission(media: media, completionHandler: { result in DispatchQueue.main.async { switch result { case .success(_): break; case .failure(_): errors = true; } group.leave(); } }) } group.leave(); group.notify(queue: DispatchQueue.main, execute: { completionHandler(errors ? .failure(ErrorCondition.forbidden) : .success(Void())); }) } static func checkAccesssPermission(media: Call.Media, completionHandler: @escaping(Result)->Void) { switch AVCaptureDevice.authorizationStatus(for: media.avmedia) { case .authorized: completionHandler(.success(Void())); case .denied, .restricted: completionHandler(.failure(ErrorCondition.forbidden)); case .notDetermined: AVCaptureDevice.requestAccess(for: media.avmedia, completionHandler: { result in completionHandler(result ? .success(Void()) : .failure(ErrorCondition.forbidden)); }) default: completionHandler(.failure(ErrorCondition.forbidden)); } } static func call(jid: BareJID, from account: BareJID, media: [Call.Media], sender: UIViewController) { call(jid: jid, from: account, media: media, completionHandler: { result in switch result { case .success(_): break; case .failure(let err): var message = NSLocalizedString("It was not possible to establish call", comment: "error message"); if let e = err as? ErrorCondition { switch e { case .forbidden: message = NSLocalizedString("It was not possible to access camera or microphone. Please check privacy settings", comment: "error message"); default: break; } } DispatchQueue.main.async { let alert = UIAlertController(title: NSLocalizedString("Call failed", comment: "alert title"), message: message, preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: nil)); sender.present(alert, animated: true, completion: nil); } } }); } static func call(jid: BareJID, from account: BareJID, media: [Call.Media], completionHandler: @escaping (Result)->Void) { guard let instance = CallManager.instance else { completionHandler(.failure(ErrorCondition.not_allowed)) return; } guard let client = XmppService.instance.getClient(for: account) else { completionHandler(.failure(ErrorCondition.item_not_found)); return; } let continueCall = { // we do not know "internal id" of a session let call = Call(client: client, with: jid, sid: UUID().uuidString, direction: .outgoing, media: media); checkMediaAvailability(forCall: call, completionHandler: { result in switch result { case .success(_): instance.reportOutgoingCall(call, completionHandler: completionHandler); case .failure(let err): completionHandler(.failure(err)); } }) }; AVCaptureDevice.requestAccess(for: .audio, completionHandler: { result in if result { if media.contains(.video) { AVCaptureDevice.requestAccess(for: .audio, completionHandler: { result in if result { continueCall(); } else { completionHandler(.failure(ErrorCondition.not_allowed)) } }); } else { continueCall(); } } else { completionHandler(.failure(ErrorCondition.not_allowed)) } }); } @IBOutlet var titleLabel: UILabel?; @IBOutlet var remoteVideoView: RTCMTLVideoView!; @IBOutlet var localVideoView: RTCMTLVideoView!; @IBOutlet fileprivate var avatar: AvatarView?; @IBOutlet fileprivate var avatarWidthConstraint: NSLayoutConstraint!; @IBOutlet fileprivate var avatarHeightConstraint: NSLayoutConstraint!; private var audioSession: AudioSesion?; public override func viewDidLoad() { super.viewDidLoad(); localVideoView.layer.cornerRadius = 5; self.updateStateLabel(); self.updateAvatarView(); let mtkview = self.view.subviews.last!; self.view.sendSubviewToBack(mtkview); // remoteVideoView.delegate = self; NotificationCenter.default.addObserver(self, selector: #selector(orientationChanged), name: UIDevice.orientationDidChangeNotification, object: nil) } // var timer: Foundation.Timer?; public override func viewWillAppear(_ animated: Bool) { self.updateAvatarView(); super.viewWillAppear(animated); self.orientationChanged(); } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated); } @objc func orientationChanged() { switch UIDevice.current.orientation { case .portrait, .portraitUpsideDown: self.avatarHeightConstraint.isActive = false; self.avatarWidthConstraint.isActive = true; default: self.avatarHeightConstraint.isActive = true; self.avatarWidthConstraint.isActive = false; } } @IBAction func switchCamera(_ sender: UIButton) { call?.switchCameraDevice(); } @IBAction func selectAudioDevice(_ sender: UIButton) { let controller = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet); for audioPort in audioSession?.availableAudioPorts() ?? [] { let action = UIAlertAction(title: audioPort.label, style: .default, handler: { action in self.audioSession?.set(outputMode: audioPort); }); switch audioPort { case .automatic: break; case .builtin: action.setValue(AVAudioSession.sharedInstance().currentRoute.outputs.contains(where: { $0.portType == .builtInReceiver }), forKey: "checked") case .speaker: action.setValue(AVAudioSession.sharedInstance().currentRoute.outputs.contains(where: { $0.portType == .builtInSpeaker }), forKey: "checked") case .custom(let port): action.setValue(AVAudioSession.sharedInstance().currentRoute.inputs.contains(where: { $0.portType == port.portType }), forKey: "checked"); } if let image = audioPort.icon { action.setValue(image.scaled(maxWidthOrHeight: 30, isOpaque: false), forKey: "image"); } controller.addAction(action) } controller.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: nil)); controller.popoverPresentationController?.sourceView = sender; controller.popoverPresentationController?.sourceRect = sender.bounds; self.present(controller, animated: true, completion: nil); } fileprivate var muted: Bool = false; @IBAction func mute(_ sender: UIButton) { self.muted = !self.muted; if let instance = CallManager.instance, let call = self.call { instance.muteCall(call, value: self.muted); } sender.backgroundColor = self.muted ? UIColor.red : UIColor.white; sender.tintColor = self.muted ? UIColor.white : UIColor.black; } @IBAction func disconnectClicked(_ sender: UIButton) { if let instance = CallManager.instance, let call = self.call { instance.endCall(call); } dismiss(); } func dismiss() { DispatchQueue.main.async { self.dismiss(animated: true, completion: nil); } } private func updateAvatarVisibility() { DispatchQueue.main.async { self.avatar?.isHidden = self.remoteVideoTrack != nil && (self.call?.state ?? .new) == .connected; } } private func updateAvatarView() { if let call = self.call { avatar?.set(name: DBRosterStore.instance.item(for: call.account, jid: JID(call.jid))?.name ?? call.jid.stringValue, avatar: AvatarManager.instance.avatar(for: call.jid, on: call.account)); } else { avatar?.set(name: nil, avatar: nil); } } fileprivate func updateStateLabel() { DispatchQueue.main.async { self.updateAvatarVisibility(); switch self.call?.state ?? .new { case .new: self.titleLabel?.text = NSLocalizedString("New call", comment: "call state label"); case .ringing: self.titleLabel?.text = NSLocalizedString("Ringing…", comment: "call state label"); case .connecting: self.titleLabel?.text = NSLocalizedString("Connecting…", comment: "call state label"); case .connected: self.titleLabel?.text = nil; case .ended: self.titleLabel?.text = NSLocalizedString("Call ended", comment: "call state label"); } } } // #endif static var peerConnectionFactory: RTCPeerConnectionFactory { return JingleManager.instance.connectionFactory; } static let defaultCallConstraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil); static func initiatePeerConnection(iceServers servers: [RTCIceServer], withDelegate delegate: RTCPeerConnectionDelegate) -> RTCPeerConnection? { let iceServers = (servers.isEmpty && Settings.usePublicStunServers) ? [ RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302","stun:stun1.l.google.com:19302","stun:stun2.l.google.com:19302","stun:stun3.l.google.com:19302","stun:stun4.l.google.com:19302"]), RTCIceServer(urlStrings: ["stun:stunserver.org:3478"]) ] : servers; os_log("using ICE servers: %s", log: .jingle, type: .debug, iceServers.map({ $0.urlStrings.description }).description); let configuration = RTCConfiguration(); configuration.tcpCandidatePolicy = .disabled; configuration.sdpSemantics = .unifiedPlan; configuration.iceServers = iceServers; configuration.bundlePolicy = .maxCompat; configuration.rtcpMuxPolicy = .require; configuration.iceCandidatePoolSize = 5; return JingleManager.instance.connectionFactory.peerConnection(with: configuration, constraints: defaultCallConstraints, delegate: delegate); } } ================================================ FILE: SiskinIM/xmpp/HttpFileUploadModule.swift ================================================ // // HttpFileUploadModule.swift // // Siskin IM // Copyright (C) 2022 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import Foundation import Combine import Martin // Dummy implementation - it would be better to replace it with some better feature discovery than on each reconnection class HttpFileUploadModule: Martin.HttpFileUploadModule { @Published var isAvailable: Bool = true; var isAvailablePublisher: AnyPublisher { return $isAvailable.eraseToAnyPublisher(); } } ================================================ FILE: SiskinIM/xmpp/SiskinPushNotificationsModule.swift ================================================ // // SiskinPushNotificationsModule.swift // // Siskin IM // Copyright (C) 2019 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Foundation import UserNotifications import Shared import Martin open class SiskinPushNotificationsModule: TigasePushNotificationsModule { public struct PushSettings { public let jid: JID; public let node: String; public let deviceId: String; public let pushkitDeviceId: String?; public let encryption: Bool; public let maxSize: Int?; init?(dictionary: [String: Any]?) { guard let dict = dictionary else { return nil; } guard let jid = JID(dict["jid"] as? String), let node = dict["node"] as? String, let deviceId = dict["device"] as? String else { return nil; } self.init(jid: jid, node: node, deviceId: deviceId, pushkitDeviceId: dict["pushkitDevice"] as? String, encryption: dict["encryption"] as? Bool ?? false, maxSize: dict["maxSize"] as? Int); } init(jid: JID, node: String, deviceId: String, pushkitDeviceId: String? = nil, encryption: Bool, maxSize: Int?) { self.jid = jid; self.node = node; self.deviceId = deviceId; self.pushkitDeviceId = pushkitDeviceId; self.encryption = encryption; self.maxSize = maxSize; } func dictionary() -> [String: Any] { var dict: [String: Any] = ["jid": jid.stringValue, "node": node, "device": deviceId]; if let pushkitDevice = self.pushkitDeviceId { dict["pushkitDevice"] = pushkitDevice; } if encryption { dict["encryption"] = true; } if maxSize != nil { dict["maxSize"] = maxSize; } return dict; } } open var pushSettings: PushSettings?; open var isEnabled: Bool { return pushSettings != nil; } open func isEnabled(for deviceId: String) -> Bool { guard let settings = self.pushSettings else { return false; } return settings.deviceId == deviceId; } public let defaultPushServiceJid: JID; fileprivate let providerId = "tigase:messenger:apns:1"; fileprivate let provider: SiskinPushNotificationsModuleProviderProtocol; public init(defaultPushServiceJid: JID, provider: SiskinPushNotificationsModuleProviderProtocol) { self.defaultPushServiceJid = defaultPushServiceJid; self.provider = provider; super.init(); } open func registerDeviceAndEnable(deviceId: String, pushkitDeviceId: String?, completionHandler: @escaping (Result)->Void) { self.findPushComponent { result in switch result { case .success(let jid): self.registerDeviceAndEnable(deviceId: deviceId, pushkitDeviceId: pushkitDeviceId, pushServiceJid: jid, completionHandler: completionHandler); case .failure(_): self.registerDeviceAndEnable(deviceId: deviceId, pushkitDeviceId: pushkitDeviceId, pushServiceJid: self.defaultPushServiceJid, completionHandler: completionHandler); } } } private func prepareExtensions(for context: Context, componentSupportsEncryption: Bool, maxSize: Int?) -> [PushNotificationsModuleExtension] { var extensions: [PushNotificationsModuleExtension] = []; if !Settings.notificationsFromUnknown { if self.isSupported(extension: TigasePushNotificationsModule.IgnoreUnknown.self) { extensions.append(TigasePushNotificationsModule.IgnoreUnknown()); } } let account = context.userBareJid; let groupchatFilter = self.isSupported(extension: TigasePushNotificationsModule.GroupchatFilter.self); if groupchatFilter { extensions.append(TigasePushNotificationsModule.GroupchatFilter(rules: provider.groupchatFilterRules(for: context))); } let muted = self.isSupported(extension: TigasePushNotificationsModule.Muted.self) if muted { extensions.append(TigasePushNotificationsModule.Muted(jids: provider.mutedChats(for: context))); } if muted && groupchatFilter { let priority = self.isSupported(extension: TigasePushNotificationsModule.Priority.self); if priority { extensions.append(TigasePushNotificationsModule.Priority()); if componentSupportsEncryption && self.isSupported(extension: TigasePushNotificationsModule.Encryption.self) && self.isSupported(feature: TigasePushNotificationsModule.Encryption.AES_128_GCM) { extensions.append(TigasePushNotificationsModule.Encryption(algorithm: TigasePushNotificationsModule.Encryption.AES_128_GCM.replacingOccurrences(of: "tigase:push:encrypt:", with: ""), key: NotificationEncryptionKeys.key(for: account) ?? Cipher.AES_GCM.generateKey(ofSize: 128)!, maxPayloadSize: maxSize)); } } } if AccountSettings.pushNotificationsForAway(for: context.userBareJid) { extensions.append(TigasePushNotificationsModule.PushForAway()); } if self.isSupported(extension: TigasePushNotificationsModule.Jingle.self) { extensions.append(TigasePushNotificationsModule.Jingle()); } return extensions; } open func registerDeviceAndEnable(deviceId: String, pushkitDeviceId: String? = nil, pushServiceJid: JID, completionHandler: @escaping (Result)->Void) { self.registerDevice(serviceJid: pushServiceJid, provider: self.providerId, deviceId: deviceId, pushkitDeviceId: pushkitDeviceId, completionHandler: { (result) in switch result { case .success(let data): self.enable(serviceJid: pushServiceJid, node: data.node, deviceId: deviceId, pushkitDeviceId: pushkitDeviceId, features: data.features ?? [], maxSize: data.maxPayloadSize, completionHandler: completionHandler); case .failure(let err): completionHandler(.failure(err)); } }); } open func reenable(pushSettings: PushSettings, completionHandler: @escaping (Result)->Void) { self.enable(serviceJid: pushSettings.jid, node: pushSettings.node, deviceId: pushSettings.deviceId, features: pushSettings.encryption ? [TigasePushNotificationsModule.Encryption.XMLNS] : [], maxSize: pushSettings.maxSize, completionHandler: completionHandler); } private func hash(extensions: [PushNotificationsModuleExtension]) -> Int { var hasher = Hasher(); for ext in extensions { ext.hash(into: &hasher); } let hash = hasher.finalize(); if hash == 0 { return 1; } return hash; } private func enable(serviceJid: JID, node: String, deviceId: String, pushkitDeviceId: String? = nil, features: [String], maxSize: Int?, publishOptions: JabberDataElement? = nil, completionHandler: @escaping (Result)->Void) { guard let context = self.context else { completionHandler(.failure(.remote_server_timeout)); return; } let extensions: [PushNotificationsModuleExtension] = self.prepareExtensions(for: context, componentSupportsEncryption: features.contains(TigasePushNotificationsModule.Encryption.XMLNS), maxSize: maxSize); let newHash = hash(extensions: extensions); if let oldSettings = self.pushSettings { guard newHash != AccountSettings.pushHash(for: context.userBareJid) else { completionHandler(.success(oldSettings)); return; } } let encryption = extensions.first(where: { ext in return ext is TigasePushNotificationsModule.Encryption; }) as? TigasePushNotificationsModule.Encryption; let settings = PushSettings(jid: serviceJid, node: node, deviceId: deviceId, pushkitDeviceId: pushkitDeviceId, encryption: encryption != nil, maxSize: maxSize); self.enable(serviceJid: serviceJid, node: node, extensions: extensions, completionHandler: { (result) in switch result { case .success(_): let accountJid = context.userBareJid; NotificationEncryptionKeys.set(key: encryption?.key, for: accountJid); AccountSettings.pushHash(for: accountJid, value: newHash); self.pushSettings = settings; if var config = AccountManager.getAccount(for: accountJid) { config.pushSettings = settings; config.pushNotifications = true; try? AccountManager.save(account: config, reconnect: false); } completionHandler(.success(settings)); case .failure(let err): self.unregisterDevice(serviceJid: serviceJid, provider: self.providerId, deviceId: deviceId, completionHandler: { result in completionHandler(.failure(err)); }); } }); } public func unregisterDeviceAndDisable(completionHandler: @escaping (Result) -> Void) { if let settings = self.pushSettings, let context = self.context { var total: Result = .success(Void()); let group = DispatchGroup(); group.enter(); group.enter(); AccountSettings.pushHash(for: context.userBareJid, value: 0); let resultHandler: (Result)->Void = { result in DispatchQueue.main.async { switch result { case .failure(let error): if error != .item_not_found { total = .failure(error); } default: break; } group.leave(); } } group.notify(queue: DispatchQueue.main) { self.pushSettings = nil; let accountJid = context.userBareJid; NotificationEncryptionKeys.set(key: nil, for: accountJid); if var config = AccountManager.getAccount(for: accountJid) { config.pushSettings = nil; config.pushNotifications = false; try? AccountManager.save(account: config, reconnect: false); } completionHandler(total); } self.disable(serviceJid: settings.jid, node: settings.node, completionHandler: { result in switch result { case .success(_): resultHandler(.success(Void())); case .failure(let err): resultHandler(.failure(err)); } }); self.unregisterDevice(serviceJid: settings.jid, provider: self.providerId, deviceId: settings.deviceId, completionHandler: resultHandler); } else { completionHandler(.failure(.remote_server_not_found())); } } func findPushComponent(completionHandler: @escaping (Result)->Void) { self.findPushComponent(requiredFeatures: ["urn:xmpp:push:0", self.providerId], completionHandler: completionHandler); } } public protocol SiskinPushNotificationsModuleProviderProtocol { func mutedChats(for context: Context) -> [BareJID]; func groupchatFilterRules(for context: Context) -> [TigasePushNotificationsModule.GroupchatFilter.Rule]; } ================================================ FILE: SiskinIM - Share/Assets.xcassets/AppIcon-Simple.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, { "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, { "idiom" : "iphone", "scale" : "1x", "size" : "29x29" }, { "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, { "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, { "filename" : "80.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, { "filename" : "120 1.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, { "idiom" : "iphone", "scale" : "1x", "size" : "57x57" }, { "idiom" : "iphone", "scale" : "2x", "size" : "57x57" }, { "filename" : "120.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, { "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, { "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, { "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, { "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, { "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, { "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, { "idiom" : "ipad", "scale" : "1x", "size" : "50x50" }, { "idiom" : "ipad", "scale" : "2x", "size" : "50x50" }, { "filename" : "72.png", "idiom" : "ipad", "scale" : "1x", "size" : "72x72" }, { "idiom" : "ipad", "scale" : "2x", "size" : "72x72" }, { "filename" : "76.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, { "filename" : "152.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, { "filename" : "167.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, { "filename" : "1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: SiskinIM - Share/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, { "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, { "idiom" : "iphone", "scale" : "1x", "size" : "29x29" }, { "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, { "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, { "filename" : "80.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, { "filename" : "120 1.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, { "idiom" : "iphone", "scale" : "1x", "size" : "57x57" }, { "idiom" : "iphone", "scale" : "2x", "size" : "57x57" }, { "filename" : "120.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, { "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, { "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, { "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, { "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, { "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, { "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, { "idiom" : "ipad", "scale" : "1x", "size" : "50x50" }, { "idiom" : "ipad", "scale" : "2x", "size" : "50x50" }, { "filename" : "72.png", "idiom" : "ipad", "scale" : "1x", "size" : "72x72" }, { "idiom" : "ipad", "scale" : "2x", "size" : "72x72" }, { "filename" : "76.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, { "filename" : "152.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, { "filename" : "167.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, { "filename" : "1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: SiskinIM - Share/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: SiskinIM - Share/Info.plist ================================================ CFBundleDevelopmentRegion en CFBundleDisplayName Share with Siskin IM CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType XPC! CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) NSAppTransportSecurity NSAllowsArbitraryLoads NSExtension NSExtensionAttributes NSExtensionActivationRule NSExtensionActivationDictionaryVersion 2 NSExtensionActivationSupportsAttachmentsWithMaxCount 1 NSExtensionActivationSupportsFileWithMaxCount 1 NSExtensionActivationSupportsImageWithMaxCount 1 NSExtensionActivationSupportsMovieWithMaxCount 1 NSExtensionActivationSupportsText NSExtensionActivationSupportsWebURLWithMaxCount 1 NSExtensionMainStoryboard MainInterface NSExtensionPointIdentifier com.apple.share-services ================================================ FILE: SiskinIM - Share/ShareViewController.swift ================================================ // // ShareViewController.swift // // Siskin IM // Copyright (C) 2017 "Tigase, Inc." // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. Look for COPYING file in the top folder. // If not, see https://www.gnu.org/licenses/. // import UIKit import Social import Shared import Martin import TigaseSQLite3 import MobileCoreServices import Combine extension Query { static let selectRosterItems = Query("SELECT ri.account, ri.jid, ri.name, ri.data FROM roster_items ri"); static let selectAvatars = Query("select ac.account, ac.jid, ac.type, ac.hash FROM avatars_cache ac"); } enum AvatarType: String { case vcardTemp case pepUserAvatar } struct AvatarKey: Hashable { let account: BareJID; let jid: BareJID; let type: AvatarType; } struct RosterItem: Equatable { let account: BareJID; let jid: BareJID; let name: String?; var displayName: String { return name ?? jid.stringValue; } var initials: String? { let parts = displayName.uppercased().components(separatedBy: CharacterSet.letters.inverted); let first = parts.first?.first; let last = parts.count > 1 ? parts.last?.first : nil; return (last == nil || first == nil) ? (first == nil ? nil : "\(first!)") : "\(first!)\(last!)"; } } struct DBRosterData: Codable, DatabaseConvertibleStringValue { let groups: [String]; let annotations: [RosterItemAnnotation]; } class ShareViewController: UITableViewController { var recipients: [RosterItem] = []; var sharedDefaults = UserDefaults(suiteName: "group.TigaseMessenger.Share"); var avatarCacheUrl: URL?; var avatars: [AvatarKey: String] = [:]; var rosterItems: [RosterItem] = []; var imageQuality: ImageQuality { if let valueStr = sharedDefaults?.string(forKey: "imageQuality"), let value = ImageQuality(rawValue: valueStr) { return value; } return .medium; } var videoQuality: VideoQuality { if let valueStr = sharedDefaults?.string(forKey: "videoQuality"), let value = VideoQuality(rawValue: valueStr) { return value; } return .medium; } override func viewDidLoad() { super.viewDidLoad(); self.navigationItem.title = NSLocalizedString("Select recipients", comment: "view title"); self.navigationItem.setLeftBarButton(UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped(_:))), animated: false); self.navigationItem.setRightBarButton(UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneTapped(_:))), animated: false) self.navigationItem.rightBarButtonItem?.isEnabled = false; let dbUrl = Database.mainDatabaseUrl(); if !FileManager.default.fileExists(atPath: dbUrl.path) { let controller = UIAlertController(title: NSLocalizedString("Please launch application from the home screen before continuing.", comment: "alert title"), message: nil, preferredStyle: .alert); controller.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .destructive, handler: { (action) in self.extensionContext?.cancelRequest(withError: ShareError.unknownError); })) self.present(controller, animated: true, completion: nil); } let database = try! Database(path: dbUrl.path, flags: SQLITE_OPEN_WAL | SQLITE_OPEN_READONLY); let accounts = Set(getActiveAccounts()); try! database.select(query: .selectAvatars, params: []).forEach({ c in guard let account = c.bareJid(for: "account"), let jid = c.bareJid(for: "jid"), let type = AvatarType(rawValue: c.string(for: "type")!), let hash = c.string(for: "hash") else { return; } avatars[.init(account: account, jid: jid, type: type)] = hash; }) rosterItems = try! database.select(query: .selectRosterItems, cached: false, params: []).mapAll({ c -> RosterItem? in guard let account = c.bareJid(for: "account"), accounts.contains(account), let jid = c.bareJid(for: "jid") else { return nil; } if let data: DBRosterData = c.object(for: "data") { guard data.annotations.isEmpty else { return nil; } } return RosterItem(account: account, jid: jid, name: c.string(for: "name")); }).sorted(by: { r1, r2 -> Bool in return r1.displayName.lowercased() < r2.displayName.lowercased(); }) avatarCacheUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.siskinim.shared")!.appendingPathComponent("Library", isDirectory: true).appendingPathComponent("Caches", isDirectory: true).appendingPathComponent("avatars", isDirectory: true); for url in try! FileManager.default.contentsOfDirectory(at: FileManager.default.temporaryDirectory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) { try? FileManager.default.removeItem(at: url); } } private var alertController: UIAlertController?; override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated); if !sharedDefaults!.bool(forKey: "SharingViaHttpUpload") { var error = true; if let provider = (self.extensionContext!.inputItems.first as? NSExtensionItem)?.attachments?.first { error = !provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String); } if error { self.showAlert(title: NSLocalizedString("Failure", comment: "alert title"), message: NSLocalizedString("Sharing feature with HTTP upload is disabled within application. To use this feature you need to enable sharing with HTTP upload in application", comment: "alert body")); } } } @objc func cancelTapped(_ sender: Any) { let error = NSError(domain: "tigase.siskinim", code: 0, userInfo: [:]); self.extensionContext?.cancelRequest(withError: error); } private var cancellables: Set = []; private var cancelled = false; private var clients: [XMPPClient] = []; @objc func doneTapped(_ sender: Any) { self.navigationItem.rightBarButtonItem?.isEnabled = false; alertController = UIAlertController(title: "", message: nil, preferredStyle: .alert); let activityIndicator = UIActivityIndicatorView(style: .medium); activityIndicator.startAnimating(); let label = UILabel(frame: .zero); label.text = NSLocalizedString("Preparing…", comment: "operation label"); let stack = UIStackView(arrangedSubviews: [activityIndicator, label]); stack.alignment = .center; stack.distribution = .fillProportionally; stack.axis = .horizontal; stack.translatesAutoresizingMaskIntoConstraints = false; stack.spacing = 14; alertController?.view.addSubview(stack); NSLayoutConstraint.activate([ stack.centerXAnchor.constraint(equalTo: alertController!.view.centerXAnchor), stack.leadingAnchor.constraint(greaterThanOrEqualTo: alertController!.view.leadingAnchor, constant: 20), stack.topAnchor.constraint(equalTo: alertController!.view.topAnchor, constant: 20), stack.bottomAnchor.constraint(equalTo: alertController!.view.bottomAnchor, constant: -60) ]) alertController?.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "button label"), style: .cancel, handler: { _ in self.cancelled = true; for client in self.clients { _ = client.disconnect(); } DispatchQueue.main.async { self.extensionContext?.cancelRequest(withError: ShareError.unknownError); } })); self.present(alertController!, animated: true, completion: nil); self.extractAttachments(completionHandler: { result in guard !self.cancelled else { if case let .success(att) = result, case let .file(url, _) = att { try? FileManager.default.removeItem(at: url); } return; } switch result { case .failure(let error): DispatchQueue.main.async { self.alertController?.dismiss(animated: true, completion: { self.navigationItem.rightBarButtonItem?.isEnabled = true; self.show(error: error); }) } case .success(let att): DispatchQueue.main.async { label.text = NSLocalizedString("Sending…", comment: "operation label"); } self.share(attachment: att, completionHandler: { errors in DispatchQueue.main.async { self.alertController?.dismiss(animated: true, completion: { self.navigationItem.rightBarButtonItem?.isEnabled = true; guard let error = errors.first?.error else { self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil); return; } self.show(error: error); }); } }); } }) } private struct ErrorResult { let jid: BareJID; let error: Error; } private func share(attachment: Attachment, completionHandler: @escaping ([ErrorResult])->Void) { let accounts = Set(recipients.map({ $0.account })); let group = DispatchGroup(); var errors: [ErrorResult] = []; clients = accounts.compactMap({ account -> XMPPClient? in guard let password = getAccountPassword(for: account) else { return nil; } let client = self.createXmppClient(for: account); var wasConnected = false; client.connectionConfiguration.credentials = .password(password: password, authenticationName: nil, cache: nil); client.$state.dropFirst().sink(receiveValue: { [weak client] newState in switch newState { case .connected(_): guard let client = client else { return; } wasConnected = true; let recipients = self.recipients.filter({ $0.account == account }); switch attachment { case .file(let tempUrl, let fileInfo): self.upload(file: tempUrl, fileInfo: fileInfo, using: client, completionHandler: { result in try? FileManager.default.removeItem(at: tempUrl); switch result { case .success(let url): self.send(using: client, to: recipients, body: url.absoluteString, oob: url.absoluteString, completionHandler: { _ = client.disconnect(); }) case .failure(let err): DispatchQueue.main.async { errors.append(contentsOf: recipients.map({ ErrorResult(jid: $0.jid, error: err)})) } _ = client.disconnect(); } }); case .link(let url): self.send(using: client, to: recipients, body: url.absoluteString, oob: nil, completionHandler: { _ = client.disconnect(); }) case .text(let text): self.send(using: client, to: recipients, body: text, oob: nil, completionHandler: { _ = client.disconnect(); }) } break; case .disconnected: if !wasConnected { let recipients = self.recipients.filter({ $0.account == account }); DispatchQueue.main.async { errors.append(contentsOf: recipients.map({ ErrorResult(jid: $0.jid, error: ShareError.unknownError)})) } } group.leave(); default: break; } }).store(in: &cancellables); group.enter(); client.login(); return client; }) group.notify(queue: DispatchQueue.main, execute: { completionHandler(errors); }) } private func send(using client: XMPPClient, to recipients: [RosterItem], body: String?, oob: String?, completionHandler: @escaping ()->Void) { let group = DispatchGroup(); for recipient in recipients { group.enter(); let message = Message(elem: Element(name: "message")); message.type = .chat; message.to = JID(recipient.jid) message.id = UUID().uuidString; message.body = body; message.oob = oob; client.writer.write(message, writeCompleted: { result in DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: { group.leave(); }) }); } group.notify(queue: DispatchQueue.main, execute: completionHandler); } override func numberOfSections(in tableView: UITableView) -> Int { return 1; } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return rosterItems.count; } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "recipientTableViewCell", for: indexPath); let item = rosterItems[indexPath.row]; cell.imageView?.image = avatar(for: item) ?? generateAvatar(for: item); cell.imageView?.layer.cornerRadius = 20; cell.imageView?.layer.masksToBounds = true; cell.textLabel?.text = item.displayName; cell.detailTextLabel?.text = item.jid.stringValue; if recipients.contains(item) { cell.accessoryType = .checkmark; } else { cell.accessoryType = .none; } return cell; } func avatar(for item: RosterItem) -> UIImage? { guard let hash = avatars[.init(account: item.account, jid: item.jid, type: .pepUserAvatar)] ?? avatars[.init(account: item.account, jid: item.jid, type: .vcardTemp)] else { return nil; } guard let path = avatarCacheUrl?.appendingPathComponent(hash).path else { return nil; } return UIImage(contentsOfFile: path)?.scaled(maxWidthOrHeight: 40); } func generateAvatar(for item: RosterItem) -> UIImage? { guard let initials = item.initials else { return nil; } let scale = UIScreen.main.scale; let size = CGSize(width: 40, height: 40); UIGraphicsBeginImageContextWithOptions(size, false, scale); let ctx = UIGraphicsGetCurrentContext()!; let path = CGPath(ellipseIn: CGRect(origin: .zero, size: size), transform: nil); ctx.addPath(path); let colors = [UIColor.systemGray.adjust(brightness: 0.52).cgColor, UIColor.systemGray.adjust(brightness: 0.48).cgColor]; let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors as CFArray, locations: [0.0, 1.0])!; ctx.drawLinearGradient(gradient, start: CGPoint.zero, end: CGPoint(x: 0, y: size.height), options: []); let textAttr: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.white.withAlphaComponent(0.9), .font: UIFont.systemFont(ofSize: size.width * 0.4, weight: .medium)]; let textSize = initials.size(withAttributes: textAttr); initials.draw(in: CGRect(x: size.width/2 - textSize.width/2, y: size.height/2 - textSize.height/2, width: textSize.width, height: textSize.height), withAttributes: textAttr); let image = UIGraphicsGetImageFromCurrentImageContext()!; UIGraphicsEndImageContext(); return image; } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let item = rosterItems[indexPath.row]; if let idx = recipients.firstIndex(of: item) { recipients.remove(at: idx); } else { recipients.append(item); } self.navigationItem.rightBarButtonItem?.isEnabled = !recipients.isEmpty; tableView.reloadData(); } private func createXmppClient(for account: BareJID) -> XMPPClient { let client = XMPPClient(); client.connectionConfiguration.modifyConnectorOptions(type: SocketConnectorNetwork.Options.self, { options in options.networkProcessorProviders.append(SSLProcessorProvider()); options.connectionTimeout = 15; options.sslCertificateValidation = .customValidator({ _ in return true; }) }) client.connectionConfiguration.userJid = account; _ = client.modulesManager.register(AuthModule()); _ = client.modulesManager.register(StreamFeaturesModule()); _ = client.modulesManager.register(SaslModule()); _ = client.modulesManager.register(ResourceBinderModule()); _ = client.modulesManager.register(SessionEstablishmentModule()); _ = client.modulesManager.register(DiscoveryModule()); client.modulesManager.register(PresenceModule()).initialPresence = false; _ = client.modulesManager.register(HttpFileUploadModule()); return client; } private func upload(file localUrl: URL, fileInfo: ShareFileInfo, using client: XMPPClient, completionHandler: @escaping (Result)->Void) { let uti = try? localUrl.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier; let mimeType = uti != nil ? (UTTypeCopyPreferredTagWithClass(uti! as CFString, kUTTagClassMIMEType)?.takeRetainedValue() as String?) : nil; let size = try! FileManager.default.attributesOfItem(atPath: localUrl.path)[FileAttributeKey.size] as! UInt64; guard let inputStream = InputStream(url: localUrl) else { completionHandler(.failure(ShareError.noAccessError)); return; } HTTPFileUploadHelper.upload(for: client, filename: fileInfo.filenameWithSuffix, inputStream: inputStream, filesize: Int(size), mimeType: mimeType ?? "application/octet-stream", delegate: nil, completionHandler: { result in switch result { case .success(let url): completionHandler(.success(url)); case .failure(let error): completionHandler(.failure(error)); } }) } enum Attachment { case file(URL, ShareFileInfo) case link(URL) case text(String) } private func extractAttachments(completionHandler: @escaping (Result)->Void) { if let provider = (self.extensionContext?.inputItems.first as? NSExtensionItem)?.attachments?.first { if provider.hasItemConformingToTypeIdentifier(kUTTypeVideo as String) { provider.loadFileRepresentation(forTypeIdentifier: kUTTypeVideo as String, completionHandler: { url, error in guard let url = url else { completionHandler(.failure(error!)); return; } MediaHelper.compressMovie(url: url, fileInfo: ShareFileInfo.from(url: url, defaultSuffix: "mov"), quality: self.videoQuality, progressCallback: { progress in }, completionHandler: { result in try? FileManager.default.removeItem(at: url); switch result { case .success((let url, let fileInfo)): completionHandler(.success(.file(url, fileInfo))) case .failure(let error): completionHandler(.failure(error)); } }) }); } else if provider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) { provider.loadFileRepresentation(forTypeIdentifier: kUTTypeImage as String, completionHandler: { url, error in guard let url = url else { completionHandler(.failure(error!)); return; } MediaHelper.compressImage(url: url, fileInfo: ShareFileInfo.from(url: url, defaultSuffix: "jpg"), quality: self.imageQuality, completionHandler: { result in try? FileManager.default.removeItem(at: url); switch result { case .success((let url, let fileInfo)): completionHandler(.success(.file(url, fileInfo))) case .failure(let error): completionHandler(.failure(error)); } }) }); } else if provider.hasItemConformingToTypeIdentifier(kUTTypeFileURL as String) { provider.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil, completionHandler: { (item, error) in guard let url = item as? URL else { completionHandler(.failure(error!)); return; } let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString); do { try FileManager.default.copyItem(at: url, to: tempUrl); completionHandler(.success(.file(tempUrl, ShareFileInfo.from(url: url, defaultSuffix: nil)))); } catch { completionHandler(.failure(error)); } }); } else if provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) { provider.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil, completionHandler: { (item, error) in guard let url = item as? URL else { completionHandler(.failure(error!)); return; } completionHandler(.success(.link(url))); }) } else if provider.hasItemConformingToTypeIdentifier(kUTTypeText as String) { provider.loadItem(forTypeIdentifier: kUTTypeText as String, options: nil, completionHandler: { (item, error) in guard let text = item as? String else { completionHandler(.failure(error!)); return; } completionHandler(.success(.text(text))); }) } else { completionHandler(.failure(ShareError.notSupported)); } } else { completionHandler(.failure(ShareError.noAccessError)); } } func show(error: Error) { showAlert(title: NSLocalizedString("Failure", comment: "alert title"), message: (error as? ShareError)?.message ?? error.localizedDescription); } func showAlert(title: String, message: String) { DispatchQueue.main.async { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert); alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "button label"), style: .default, handler: {(action) in self.extensionContext?.cancelRequest(withError: ShareError.unknownError); })); self.present(alert, animated: true, completion: nil); } } func getActiveAccounts() -> [BareJID] { var accounts = [BareJID](); let query = [ String(kSecClass) : kSecClassGenericPassword, String(kSecMatchLimit) : kSecMatchLimitAll, String(kSecReturnAttributes) : kCFBooleanTrue as Any, String(kSecAttrService) : "xmpp" ] as [String : Any]; var result:AnyObject?; let lastResultCode: OSStatus = withUnsafeMutablePointer(to: &result) { SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)); } if lastResultCode == noErr { if let results = result as? [[String:NSObject]] { for r in results { if let name = r[String(kSecAttrAccount)] as? String { if let data = r[String(kSecAttrGeneric)] as? NSData { NSKeyedUnarchiver.setClass(ServerCertificateInfo.self, forClassName: "Siskin.ServerCertificateInfo"); let dict = NSKeyedUnarchiver.unarchiveObject(with: data as Data) as? [String:AnyObject]; if dict!["active"] as? Bool ?? true { accounts.append(BareJID(name)); } } } } } } return accounts; } func getAccountPassword(for account: BareJID) -> String? { let query: [String: NSObject] = [ String(kSecClass) : kSecClassGenericPassword, String(kSecMatchLimit) : kSecMatchLimitOne, String(kSecReturnData) : kCFBooleanTrue, String(kSecAttrService) : "xmpp" as NSObject, String(kSecAttrAccount) : account.stringValue as NSObject ]; var result:AnyObject?; let lastResultCode: OSStatus = withUnsafeMutablePointer(to: &result) { SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)); } if lastResultCode == noErr { if let data = result as? NSData { return String(data: data as Data, encoding: String.Encoding.utf8); } } return nil; } } ================================================ FILE: SiskinIM - Share/SiskinIM - Share.entitlements ================================================ com.apple.security.application-groups group.siskinim.shared group.TigaseMessenger.Share keychain-access-groups $(AppIdentifierPrefix)org.tigase.messenger.mobile ================================================ FILE: SiskinIM - Share/localization/Base.lproj/MainInterface.storyboard ================================================ ================================================ FILE: SiskinIM - Share/localization/de.lproj/MainInterface.strings ================================================ /* Class = "UITableViewController"; title = "Recipients"; ObjectID = "wpy-g7-270"; */ "wpy-g7-270.title" = "Recipients"; ================================================ FILE: SiskinIM - Share/localization/en.lproj/MainInterface.strings ================================================ /* Class = "UITableViewController"; title = "Recipients"; ObjectID = "wpy-g7-270"; */ "wpy-g7-270.title" = "Recipients"; ================================================ FILE: SiskinIM - Share/localization/es.lproj/MainInterface.strings ================================================ /* Class = "UITableViewController"; title = "Recipients"; ObjectID = "wpy-g7-270"; */ "wpy-g7-270.title" = "Recipients"; ================================================ FILE: SiskinIM - Share/localization/pl.lproj/MainInterface.strings ================================================ /* Class = "UITableViewController"; title = "Recipients"; ObjectID = "wpy-g7-270"; */ "wpy-g7-270.title" = "Odbiorcy"; ================================================ FILE: SiskinIM.entitlements ================================================ aps-environment development com.apple.developer.usernotifications.communication com.apple.security.application-groups group.TigaseMessenger.Share group.siskinim.shared group.siskinim.notifications keychain-access-groups $(AppIdentifierPrefix)org.tigase.messenger.mobile ================================================ FILE: SiskinIM.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 54; objects = { /* Begin PBXBuildFile section */ FE00157D2017617B00490340 /* StreamFeaturesCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE00157C2017617B00490340 /* StreamFeaturesCache.swift */; }; FE00157F2019090300490340 /* ExperimentalSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE00157E2019090300490340 /* ExperimentalSettingsViewController.swift */; }; FE01ADA91E224CF400FA7E65 /* SiskinPushNotificationsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01ADA81E224CF400FA7E65 /* SiskinPushNotificationsModule.swift */; }; FE0AE0B426A9DD6D0010D2E2 /* SetAccountSettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0AE0B326A9DD6D0010D2E2 /* SetAccountSettingsController.swift */; }; FE0AE0B626AED7870010D2E2 /* GetInTouchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0AE0B526AED7870010D2E2 /* GetInTouchViewController.swift */; }; FE0AE13526B575820010D2E2 /* MeetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0AE13426B575820010D2E2 /* MeetController.swift */; }; FE0AE13726B58C4F0010D2E2 /* MeetManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0AE13626B58C4F0010D2E2 /* MeetManager.swift */; }; FE0AE13926B7EA770010D2E2 /* MeetEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0AE13826B7EA770010D2E2 /* MeetEventHandler.swift */; }; FE0E30DF2535B9D20030F8C5 /* BaseChatViewController+Share.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0E30DE2535B9D20030F8C5 /* BaseChatViewController+Share.swift */; }; FE0E30E92535BA910030F8C5 /* BaseChatViewController+ShareMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0E30E82535BA910030F8C5 /* BaseChatViewController+ShareMedia.swift */; }; FE0E30F12535BB530030F8C5 /* BaseChatViewController+ShareFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0E30F02535BB530030F8C5 /* BaseChatViewController+ShareFile.swift */; }; FE0E30FE253714050030F8C5 /* MediaHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0E30FD253714050030F8C5 /* MediaHelper.swift */; }; FE0E31122537288A0030F8C5 /* MediaSettingsVIewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0E31112537288A0030F8C5 /* MediaSettingsVIewController.swift */; }; FE11D2E026616A4F00CC883F /* DBRosterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A061CDB7B3B001A015C /* DBRosterStore.swift */; }; FE11D2E426616B7300CC883F /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA518A32661593B00523EF2 /* Database.swift */; }; FE11D2E526616B7300CC883F /* DatabaseMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA518B82661618D00523EF2 /* DatabaseMigrator.swift */; }; FE11D2EA26616F0100CC883F /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D2E926616F0100CC883F /* Database.swift */; }; FE11D33E26623D6200CC883F /* AppendixProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D30826623A6200CC883F /* AppendixProtocol.swift */; }; FE11D35026623D6800CC883F /* ConversationEntryEncryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D30526623A6200CC883F /* ConversationEntryEncryption.swift */; }; FE11D35126623D6800CC883F /* ConversationKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D30E26623A6300CC883F /* ConversationKey.swift */; }; FE11D35226623D6800CC883F /* ConversationEntryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D30D26623A6300CC883F /* ConversationEntryState.swift */; }; FE11D35326623D6800CC883F /* ConversationEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D30C26623A6200CC883F /* ConversationEntry.swift */; }; FE11D35426623D6800CC883F /* ConversationInvitation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D30B26623A6200CC883F /* ConversationInvitation.swift */; }; FE11D35526623D6800CC883F /* ConversationEntryRecipient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D30926623A6200CC883F /* ConversationEntryRecipient.swift */; }; FE11D35626623D6800CC883F /* ConversationAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D30726623A6200CC883F /* ConversationAttachment.swift */; }; FE11D35726623D6800CC883F /* ConversationEntrySender.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D30A26623A6200CC883F /* ConversationEntrySender.swift */; }; FE11D35B26623D6D00CC883F /* AccountConversations.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D2FB26623A5900CC883F /* AccountConversations.swift */; }; FE11D35C26623D6D00CC883F /* Room.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D2F826623A5900CC883F /* Room.swift */; }; FE11D35D26623D6D00CC883F /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D2F726623A5900CC883F /* Channel.swift */; }; FE11D35E26623D6D00CC883F /* ConversationBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D2F626623A5900CC883F /* ConversationBase.swift */; }; FE11D35F26623D6D00CC883F /* Conversation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D2F926623A5900CC883F /* Conversation.swift */; }; FE11D36026623D6D00CC883F /* Chat.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D2FA26623A5900CC883F /* Chat.swift */; }; FE11D36726623D7000CC883F /* DisplayableIdProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D2F12662394B00CC883F /* DisplayableIdProtocol.swift */; }; FE11D37E26623D7700CC883F /* DBChatStore+ChannelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D32726623C7700CC883F /* DBChatStore+ChannelStore.swift */; }; FE11D37F26623D7700CC883F /* DBVCardStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D33626623D1E00CC883F /* DBVCardStore.swift */; }; FE11D38026623D7700CC883F /* DBChatMarkersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D32226623C2F00CC883F /* DBChatMarkersStore.swift */; }; FE11D38126623D7700CC883F /* DBChatStore+RoomStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D33126623CD000CC883F /* DBChatStore+RoomStore.swift */; }; FE11D38226623D7700CC883F /* DBChatStore+ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D32C26623CA300CC883F /* DBChatStore+ChatStore.swift */; }; FE11D39826623EC500CC883F /* ConversationDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D39026623E9D00CC883F /* ConversationDataSource.swift */; }; FE11D3A126623F4100CC883F /* Array+IndexChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D39C26623F2300CC883F /* Array+IndexChanges.swift */; }; FE11D3B4266241BD00CC883F /* Publisher+ThrottledSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D3B2266241BD00CC883F /* Publisher+ThrottledSink.swift */; }; FE11D3B5266241BD00CC883F /* Publisher+OnlyGetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D3B3266241BD00CC883F /* Publisher+OnlyGetter.swift */; }; FE11D3BF2662852A00CC883F /* XMPPClient_extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D3BE2662852A00CC883F /* XMPPClient_extension.swift */; }; FE11D3C72662AB8900CC883F /* SSLProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D3C42662AB8900CC883F /* SSLProcessor.swift */; }; FE11D3C82662AB8900CC883F /* SSLContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D3C52662AB8900CC883F /* SSLContext.swift */; }; FE11D3C92662AB8900CC883F /* SSLCertificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D3C62662AB8900CC883F /* SSLCertificate.swift */; }; FE11D3D52662B94500CC883F /* PresenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D3D02662B91E00CC883F /* PresenceStore.swift */; }; FE11D3E126639D3300CC883F /* ContactManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D3D926639D1600CC883F /* ContactManager.swift */; }; FE11D3ED26639E0100CC883F /* VCardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D3E526639DEA00CC883F /* VCardManager.swift */; }; FE137A4821F6464D006B7F7C /* UIColor_mix.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE137A4721F6464D006B7F7C /* UIColor_mix.swift */; }; FE137A4C21F75660006B7F7C /* ChatBottomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE137A4B21F75660006B7F7C /* ChatBottomView.swift */; }; FE17808D23EB4C7F00A1EA76 /* AccountQRCodeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE17808C23EB4C7F00A1EA76 /* AccountQRCodeController.swift */; }; FE1908AA2584D6BC00CA049F /* OpenSSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE1908962584D69400CA049F /* OpenSSL.xcframework */; }; FE1908AB2584D6BC00CA049F /* OpenSSL.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FE1908962584D69400CA049F /* OpenSSL.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; FE1908B72584D70300CA049F /* OpenSSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE1908962584D69400CA049F /* OpenSSL.xcframework */; }; FE1A07482525EDD4004F38A0 /* ExternalServiceDiscovery_Service_extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE1A07472525EDD4004F38A0 /* ExternalServiceDiscovery_Service_extension.swift */; }; FE1A34A7258CD3EE0058B86A /* WebRTC.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE1A349F258CD3E10058B86A /* WebRTC.xcframework */; }; FE1A34A8258CD3EE0058B86A /* WebRTC.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FE1A349F258CD3E10058B86A /* WebRTC.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; FE1AC8F7216B8AB700D4CDAB /* NewFeaturesDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE1AC8F6216B8AB700D4CDAB /* NewFeaturesDetector.swift */; }; FE1DCCA21EA52CE200850563 /* DataFormController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE1DCCA11EA52CE200850563 /* DataFormController.swift */; }; FE1EE9D227862A5F000FA599 /* HttpFileUploadModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE1EE9D127862A5F000FA599 /* HttpFileUploadModule.swift */; }; FE1EE9D627903F58000FA599 /* ChannelSelectNewOwnerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE1EE9D527903F58000FA599 /* ChannelSelectNewOwnerViewController.swift */; }; FE1FA7162663AFFD0010333E /* CurrentDatePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE1FA7152663AFFD0010333E /* CurrentDatePublisher.swift */; }; FE2332DB242B9C2300008ED4 /* ChannelInviteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2332DA242B9C2300008ED4 /* ChannelInviteController.swift */; }; FE2332E1242CCDB400008ED4 /* InvitationChatTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2332E0242CCDB400008ED4 /* InvitationChatTableViewCell.swift */; }; FE2332E3242CE8D600008ED4 /* ChannelBlockedUsersController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2332E2242CE8D600008ED4 /* ChannelBlockedUsersController.swift */; }; FE233CD521E6846E00099281 /* CameraPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE233CD421E6846E00099281 /* CameraPreviewView.swift */; }; FE233CDD21EA062E00099281 /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE233CDC21EA062E00099281 /* AboutController.swift */; }; FE258EAA1F3B8BC90042CED9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FE258EA91F3B8BC90042CED9 /* Assets.xcassets */; }; FE2809812167CE18002F5BD0 /* server_features_list.xml in Resources */ = {isa = PBXBuildFile; fileRef = FE2809802167CE18002F5BD0 /* server_features_list.xml */; }; FE2809832167CF1B002F5BD0 /* ServerFeaturesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2809822167CF1B002F5BD0 /* ServerFeaturesViewController.swift */; }; FE2A00BB27F89BF500AF0152 /* Publisher+ThrottleFixed.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2A00BA27F89BF500AF0152 /* Publisher+ThrottleFixed.swift */; }; FE2D481A24505F1600C13CE5 /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2D481924505F1600C13CE5 /* CallManager.swift */; }; FE2D481C24518C2800C13CE5 /* RTCCameraVideoCapturer_Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2D481B24518C2800C13CE5 /* RTCCameraVideoCapturer_Format.swift */; }; FE31291C222C0D1500A92863 /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE31291B222C0D1500A92863 /* AvatarView.swift */; }; FE31DDE4201261A200C2AB1D /* DNSSrvDiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE31DDE3201261A200C2AB1D /* DNSSrvDiskCache.swift */; }; FE36B3C821FA52E000D1F037 /* EmptyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE36B3C721FA52E000D1F037 /* EmptyViewController.swift */; }; FE392E03289AF47D006AA914 /* MartinOMEMO in Frameworks */ = {isa = PBXBuildFile; productRef = FE392E02289AF47D006AA914 /* MartinOMEMO */; }; FE3A427526DA70B700D914CE /* ChatViewInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3A427426DA70B700D914CE /* ChatViewInputBar.swift */; }; FE3A45CF1CE49D3300C36264 /* RosterItemEditViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3A45CE1CE49D3300C36264 /* RosterItemEditViewController.swift */; }; FE3DCCEE1FE18334008B6C8B /* CertificateErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3DCCED1FE18334008B6C8B /* CertificateErrorAlert.swift */; }; FE3E387A242765E800D3A8E8 /* MixEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3E3879242765E700D3A8E8 /* MixEventHandler.swift */; }; FE3E387E2427A09A00D3A8E8 /* ChannelSelectToJoinViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3E387D2427A09A00D3A8E8 /* ChannelSelectToJoinViewController.swift */; }; FE3E38802427B8A900D3A8E8 /* ChannelsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3E387F2427B8A900D3A8E8 /* ChannelsHelper.swift */; }; FE3E38822427BF8600D3A8E8 /* ChannelSelectAccountAndComponentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3E38812427BF8600D3A8E8 /* ChannelSelectAccountAndComponentController.swift */; }; FE3E38842427E34300D3A8E8 /* ChannelJoinViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3E38832427E34300D3A8E8 /* ChannelJoinViewController.swift */; }; FE3E38862428C21100D3A8E8 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3E38852428C21100D3A8E8 /* OSLog.swift */; }; FE3E38882428D9DB00D3A8E8 /* ChannelCreateViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3E38872428D9DB00D3A8E8 /* ChannelCreateViewController.swift */; }; FE3E388C2429FAC500D3A8E8 /* ChannelSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3E388B2429FAC500D3A8E8 /* ChannelSettingsViewController.swift */; }; FE3E388E242A251E00D3A8E8 /* ChannelEditInfoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3E388D242A251E00D3A8E8 /* ChannelEditInfoController.swift */; }; FE3E3890242A98C000D3A8E8 /* ChannelParticipantsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3E388F242A98C000D3A8E8 /* ChannelParticipantsController.swift */; }; FE4071E421E2605900F09B58 /* VideoCallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4071E321E2605900F09B58 /* VideoCallController.swift */; }; FE4071E821E2653700F09B58 /* RoundButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4071E721E2653700F09B58 /* RoundButton.swift */; }; FE43E43823BF3DE80079BD9B /* ChatAttachementsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE43E43723BF3DE80079BD9B /* ChatAttachementsController.swift */; }; FE4A94AA26679D65000A96E5 /* EnumTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4A94A926679D65000A96E5 /* EnumTableViewCell.swift */; }; FE4DDF561F39E0B500A4CE5A /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4DDF551F39E0B500A4CE5A /* ShareViewController.swift */; }; FE4DDF591F39E0B500A4CE5A /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FE4DDF571F39E0B500A4CE5A /* MainInterface.storyboard */; }; FE4DDF5D1F39E0B500A4CE5A /* Siskin IM - Share.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = FE4DDF531F39E0B500A4CE5A /* Siskin IM - Share.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; FE5079F01CD3CA91001A015C /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE5079EF1CD3CA91001A015C /* Security.framework */; }; FE507A151CDB7B3B001A015C /* ChatsListTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5079FE1CDB7B3B001A015C /* ChatsListTableViewCell.swift */; }; FE507A161CDB7B3B001A015C /* ChatsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5079FF1CDB7B3B001A015C /* ChatsListViewController.swift */; }; FE507A171CDB7B3B001A015C /* ChatTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A001CDB7B3B001A015C /* ChatTableViewCell.swift */; }; FE507A181CDB7B3B001A015C /* ChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A011CDB7B3B001A015C /* ChatViewController.swift */; }; FE507A191CDB7B3B001A015C /* DBChatHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A031CDB7B3B001A015C /* DBChatHistoryStore.swift */; }; FE507A1A1CDB7B3B001A015C /* DBChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A041CDB7B3B001A015C /* DBChatStore.swift */; }; FE507A1D1CDB7B3B001A015C /* RosterItemTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A081CDB7B3B001A015C /* RosterItemTableViewCell.swift */; }; FE507A1E1CDB7B3B001A015C /* RosterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A091CDB7B3B001A015C /* RosterViewController.swift */; }; FE507A1F1CDB7B3B001A015C /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A0B1CDB7B3B001A015C /* AccountTableViewCell.swift */; }; FE507A201CDB7B3B001A015C /* AddAccountController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A0C1CDB7B3B001A015C /* AddAccountController.swift */; }; FE507A211CDB7B3B001A015C /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A0D1CDB7B3B001A015C /* SettingsViewController.swift */; }; FE507A221CDB7B3B001A015C /* AvatarStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A0F1CDB7B3B001A015C /* AvatarStatusView.swift */; }; FE507A231CDB7B3B001A015C /* CustomTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A101CDB7B3B001A015C /* CustomTabBarController.swift */; }; FE507A241CDB7B3B001A015C /* GlobalSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A111CDB7B3B001A015C /* GlobalSplitViewController.swift */; }; FE507A251CDB7B3B001A015C /* AccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A131CDB7B3B001A015C /* AccountManager.swift */; }; FE507A261CDB7B3B001A015C /* AvatarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE507A141CDB7B3B001A015C /* AvatarManager.swift */; }; FE64445E281314ED002D6E8E /* BookmarksController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE64445D281314ED002D6E8E /* BookmarksController.swift */; }; FE64446028131538002D6E8E /* BookmarkViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE64445F28131538002D6E8E /* BookmarkViewCell.swift */; }; FE644462281334ED002D6E8E /* BookmarkItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE644461281334ED002D6E8E /* BookmarkItem.swift */; }; FE6545601E9E7B85006A14AC /* RegisterAccountController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE65455F1E9E7B85006A14AC /* RegisterAccountController.swift */; }; FE6545621E9E7FDE006A14AC /* AccountDomainTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE6545611E9E7FDE006A14AC /* AccountDomainTableViewCell.swift */; }; FE6545641E9E8B67006A14AC /* ServerSelectorTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE6545631E9E8B67006A14AC /* ServerSelectorTableViewCell.swift */; }; FE65D62822E9F8EB0065DEA5 /* Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE65D62722E9F8EB0065DEA5 /* Markdown.swift */; }; FE719E762271B2BA007CEEC9 /* OpenSSL_AES_GCM_Engine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE719E752271B2BA007CEEC9 /* OpenSSL_AES_GCM_Engine.swift */; }; FE719E782271B439007CEEC9 /* MessageEncryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE719E772271B439007CEEC9 /* MessageEncryption.swift */; }; FE719E7C22730DC3007CEEC9 /* OMEMOIdentityTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE719E7B22730DC3007CEEC9 /* OMEMOIdentityTableViewCell.swift */; }; FE719E7E2274D20D007CEEC9 /* OMEMOFingerprintsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE719E7D2274D20D007CEEC9 /* OMEMOFingerprintsController.swift */; }; FE74D510234A4E1F001A925B /* ChatTableViewSystemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE74D50F234A4E1F001A925B /* ChatTableViewSystemCell.swift */; }; FE759FA42370ACA4001E78D9 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE759FA32370ACA4001E78D9 /* NotificationService.swift */; }; FE759FA82370ACA4001E78D9 /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = FE759FA12370ACA4001E78D9 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; FE759FC92370B2A4001E78D9 /* Shared.h in Headers */ = {isa = PBXBuildFile; fileRef = FE759FC72370B2A4001E78D9 /* Shared.h */; settings = {ATTRIBUTES = (Public, ); }; }; FE759FCC2370B2A4001E78D9 /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE759FC52370B2A4001E78D9 /* Shared.framework */; }; FE759FCD2370B2A4001E78D9 /* Shared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FE759FC52370B2A4001E78D9 /* Shared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; FE759FD12370B2F2001E78D9 /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE759FC52370B2A4001E78D9 /* Shared.framework */; }; FE759FDB2370B384001E78D9 /* Cipher+AES.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE759FDA2370B384001E78D9 /* Cipher+AES.swift */; }; FE759FDE2371989B001E78D9 /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = FE759FDD2371988B001E78D9 /* libsqlite3.tbd */; }; FE759FE22371C83D001E78D9 /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE759FC52370B2A4001E78D9 /* Shared.framework */; }; FE759FE82371C972001E78D9 /* libxml2.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = FE759FE72371C966001E78D9 /* libxml2.tbd */; }; FE759FF923742AC1001E78D9 /* NotificationCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE759FF823742AC1001E78D9 /* NotificationCategory.swift */; }; FE759FFC23742CE5001E78D9 /* NotificationCenterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE759FFA23742C48001E78D9 /* NotificationCenterDelegate.swift */; }; FE75A006237475E2001E78D9 /* MainNotificationManagerProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE75A004237475CD001E78D9 /* MainNotificationManagerProvider.swift */; }; FE75A008237585DC001E78D9 /* NotificationEncryptionKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE75A007237585DC001E78D9 /* NotificationEncryptionKeys.swift */; }; FE75A0102375F338001E78D9 /* PushEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE75A00E2375F324001E78D9 /* PushEventHandler.swift */; }; FE75A0122376E73C001E78D9 /* SiskinPushNotificationsModuleProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE75A0112376E73C001E78D9 /* SiskinPushNotificationsModuleProvider.swift */; }; FE7D293423B919FF001A877D /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7D293323B919FF001A877D /* DownloadManager.swift */; }; FE7D293623BB5E0A001A877D /* LinkPreviewChatTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7D293523BB5E0A001A877D /* LinkPreviewChatTableViewCell.swift */; }; FE7F645B1D281B1C00B9DF56 /* DBCapabilitiesCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7F645A1D281B1C00B9DF56 /* DBCapabilitiesCache.swift */; }; FE7F9303200FD5AC004C6195 /* AccountManagerScramSaltedPasswordCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7F9302200FD5AC004C6195 /* AccountManagerScramSaltedPasswordCache.swift */; }; FE80BDAB1D953FC4001914B0 /* SetupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE80BDAA1D953FC4001914B0 /* SetupViewController.swift */; }; FE82A729268C83020049C844 /* ChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE82A728268C83020049C844 /* ChartView.swift */; }; FE82A72B268CC0740049C844 /* DeviceMemoryUsageTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE82A72A268CC0740049C844 /* DeviceMemoryUsageTableViewCell.swift */; }; FE8885EC26BAEAAE0095E25E /* MultiContactSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8885EB26BAEAAE0095E25E /* MultiContactSelectionView.swift */; }; FE8885EE26BC6B570095E25E /* CreateMeetingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8885ED26BC6B570095E25E /* CreateMeetingViewController.swift */; }; FE8DD9C5221B153A0090F5AA /* InviteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8DD9C4221B153A0090F5AA /* InviteViewController.swift */; }; FE8DD9C7221B15DC0090F5AA /* AbstractRosterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8DD9C6221B15DC0090F5AA /* AbstractRosterViewController.swift */; }; FE94E5251CCBA74F00FAE755 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE94E5241CCBA74F00FAE755 /* AppDelegate.swift */; }; FE94E52C1CCBA74F00FAE755 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FE94E52A1CCBA74F00FAE755 /* Main.storyboard */; }; FE94E52E1CCBA74F00FAE755 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FE94E52D1CCBA74F00FAE755 /* Assets.xcassets */; }; FE94E5311CCBA74F00FAE755 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FE94E52F1CCBA74F00FAE755 /* LaunchScreen.storyboard */; }; FE9625A01D9AE7CB00D07118 /* RosterProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE96259F1D9AE7CB00D07118 /* RosterProvider.swift */; }; FE9D4DA526C1375300DD295A /* InviteToMeetingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9D4DA426C1375300DD295A /* InviteToMeetingViewController.swift */; }; FE9D4DA726C7E87C00DD295A /* AudioSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9D4DA626C7E87C00DD295A /* AudioSession.swift */; }; FE9E136D1F25F5F7005C0EE5 /* ChatSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9E136C1F25F5F7005C0EE5 /* ChatSettingsViewController.swift */; }; FE9E136F1F26049A005C0EE5 /* NotificationSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9E136E1F26049A005C0EE5 /* NotificationSettingsViewController.swift */; }; FE9E13711F2606E9005C0EE5 /* ContactsSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9E13701F2606E9005C0EE5 /* ContactsSettingsViewController.swift */; }; FE9E13731F260B33005C0EE5 /* StepperTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9E13721F260B33005C0EE5 /* StepperTableViewCell.swift */; }; FE9EA16B23BF9DB2008C401A /* ChatAttachementsCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9EA16A23BF9DB2008C401A /* ChatAttachementsCellView.swift */; }; FEA303AB24694447004A3B3E /* VCardAvatarEditCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA303AA24694447004A3B3E /* VCardAvatarEditCell.swift */; }; FEA303AD24696604004A3B3E /* VCardTextEditCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA303AC24696604004A3B3E /* VCardTextEditCell.swift */; }; FEA308D01F27A063002EF4C0 /* NavigationControllerWrappingSegue.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA308CF1F27A063002EF4C0 /* NavigationControllerWrappingSegue.swift */; }; FEA518B1266159B900523EF2 /* TigaseSQLite3 in Frameworks */ = {isa = PBXBuildFile; productRef = FEA518B0266159B900523EF2 /* TigaseSQLite3 */; }; FEA518C2266165A100523EF2 /* TigaseLogging in Frameworks */ = {isa = PBXBuildFile; productRef = FEA518C1266165A100523EF2 /* TigaseLogging */; }; FEA6DCB627148D420079DBEC /* AvatarStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEB62C4F1DA80956001500D5 /* AvatarStore.swift */; }; FEA6DCB8271890090079DBEC /* ShareLocationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA6DCB7271890090079DBEC /* ShareLocationController.swift */; }; FEA6DCBA2719DF230079DBEC /* ShareLocationSearchResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA6DCB92719DF230079DBEC /* ShareLocationSearchResultsController.swift */; }; FEA6DCBC271A01770079DBEC /* LocationChatTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA6DCBB271A01770079DBEC /* LocationChatTableViewCell.swift */; }; FEA7BF5B21E50C5800D9E36C /* JingleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA7BF5A21E50C5800D9E36C /* JingleManager.swift */; }; FEA7BF5D21E50CAB00D9E36C /* JingleManager_Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA7BF5C21E50CAB00D9E36C /* JingleManager_Session.swift */; }; FEA8D65D1F2F6AF60077C12F /* VCardEntryTypeAwareTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA8D65C1F2F6AF60077C12F /* VCardEntryTypeAwareTableViewCell.swift */; }; FEA8D6621F30F54B0077C12F /* XmppService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA8D6611F30F54B0077C12F /* XmppService.swift */; }; FEAC71791CECE50400ABABEF /* MucChatOccupantsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAC71781CECE50400ABABEF /* MucChatOccupantsTableViewController.swift */; }; FEAC717B1CECE70100ABABEF /* MucChatOccupantsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAC717A1CECE70100ABABEF /* MucChatOccupantsTableViewCell.swift */; }; FEB5EC9D1F6AE448007FE0E7 /* BaseChatViewControllerWithDataSourceContextMenuAndToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEB5EC9C1F6AE448007FE0E7 /* BaseChatViewControllerWithDataSourceContextMenuAndToolbar.swift */; }; FEBA82F926F76A8000347D89 /* VoIP.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FEBA82FB26F76A8000347D89 /* VoIP.storyboard */; }; FEBA82FC26F76A8700347D89 /* Groupchat.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FEBA82FE26F76A8700347D89 /* Groupchat.storyboard */; }; FEBA82FF26F76A8D00347D89 /* Info.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FEBA830126F76A8D00347D89 /* Info.storyboard */; }; FEBA830226F76A9300347D89 /* Settings.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FEBA830426F76A9300347D89 /* Settings.storyboard */; }; FEBA830526F76A9800347D89 /* Account.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FEBA830726F76A9800347D89 /* Account.storyboard */; }; FEBA830826F76A9C00347D89 /* Conversation.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FEBA830A26F76A9C00347D89 /* Conversation.storyboard */; }; FEBA830B26F76AA200347D89 /* MIX.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FEBA830D26F76AA200347D89 /* MIX.storyboard */; }; FEBA835426F8DF1300347D89 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = FEBA835626F8DF1300347D89 /* Localizable.strings */; }; FEBC12F524C70E7F00689475 /* DBChatHistorySyncStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEBC12F324C70E2900689475 /* DBChatHistorySyncStore.swift */; }; FEC514261CEB74F8003AF765 /* BaseChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC514251CEB74F8003AF765 /* BaseChatViewController.swift */; }; FEC514281CEB82E9003AF765 /* MucChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC514271CEB82E9003AF765 /* MucChatViewController.swift */; }; FEC79195241ABEF4007BE572 /* MessageState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC79194241ABEF4007BE572 /* MessageState.swift */; }; FECEF29423B7933A007EC323 /* MetadataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECEF29323B7933A007EC323 /* MetadataCache.swift */; }; FECEF29623B7B076007EC323 /* DownloadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECEF29523B7B076007EC323 /* DownloadStore.swift */; }; FECEF29B23B7BC02007EC323 /* BaseChatTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECEF29923B7BBD3007EC323 /* BaseChatTableViewCell.swift */; }; FECEF29E23B7C390007EC323 /* AttachmentChatTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECEF29C23B7C36F007EC323 /* AttachmentChatTableViewCell.swift */; }; FED353892270C1D000B69C53 /* DBOMEMOStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED353882270C1D000B69C53 /* DBOMEMOStore.swift */; }; FED40440283E9C5800C91BCF /* AccountConnectivitySettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED4043F283E9C5800C91BCF /* AccountConnectivitySettingsViewController.swift */; }; FEDC678A238A9F16005C0FAB /* BlockedEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDC6789238A9F16005C0FAB /* BlockedEventHandler.swift */; }; FEDC678E238B03C1005C0FAB /* AppStoryboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDC678D238B03C1005C0FAB /* AppStoryboard.swift */; }; FEDC6790238B05E4005C0FAB /* BlockedContactsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDC678F238B05E4005C0FAB /* BlockedContactsController.swift */; }; FEDCBF671D9C3EE700AE9129 /* RosterProviderFlat.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDCBF661D9C3EE700AE9129 /* RosterProviderFlat.swift */; }; FEDCBF691D9C53BA00AE9129 /* RosterProviderGrouped.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDCBF681D9C53BA00AE9129 /* RosterProviderGrouped.swift */; }; FEDE0093266BCA450019CC1C /* ConversationNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDE0092266BCA450019CC1C /* ConversationNotifications.swift */; }; FEDE0098266BCD7A0019CC1C /* ConversationType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDE0097266BCD7A0019CC1C /* ConversationType.swift */; }; FEDE009D266BE3BD0019CC1C /* NotificationsManagerHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDE009C266BE3BD0019CC1C /* NotificationsManagerHelper.swift */; }; FEDE00A8266BE4740019CC1C /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE75A00223743A5C001E78D9 /* NotificationManager.swift */; }; FEDE00B4266CC7B80019CC1C /* InvitationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDE00AF266CC7850019CC1C /* InvitationsManager.swift */; }; FEDE00D0266CEF5E0019CC1C /* ChatTableViewMarkerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDE00C8266CEF270019CC1C /* ChatTableViewMarkerCell.swift */; }; FEDE93871D07564F00CA60A9 /* SwitchTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDE93861D07564F00CA60A9 /* SwitchTableViewCell.swift */; }; FEDE93891D081C3D00CA60A9 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDE93881D081C3D00CA60A9 /* Settings.swift */; }; FEDE938C1D08AFE800CA60A9 /* VCardEditViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDE938B1D08AFE800CA60A9 /* VCardEditViewController.swift */; }; FEDE93901D09BB8300CA60A9 /* VCardEditPhoneTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDE938F1D09BB8300CA60A9 /* VCardEditPhoneTableViewCell.swift */; }; FEDE93921D09E74100CA60A9 /* VCardEditAddressTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDE93911D09E74100CA60A9 /* VCardEditAddressTableViewCell.swift */; }; FEDE93941D0AC01200CA60A9 /* VCardEditEmailTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDE93931D0AC01200CA60A9 /* VCardEditEmailTableViewCell.swift */; }; FEDE93971D0C202600CA60A9 /* ContactViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDE93961D0C202600CA60A9 /* ContactViewController.swift */; }; FEDE93991D0C207100CA60A9 /* ContactBasicTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDE93981D0C207100CA60A9 /* ContactBasicTableViewCell.swift */; }; FEDE939B1D0C38B000CA60A9 /* ContactFormTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDE939A1D0C38B000CA60A9 /* ContactFormTableViewCell.swift */; }; FEE097621F1FCE1800B1CEAB /* TablePicketViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE097611F1FCE1800B1CEAB /* TablePicketViewController.swift */; }; FEE2D188268664FF007AEE74 /* db-schema-8.sql in Resources */ = {isa = PBXBuildFile; fileRef = FE759FF423741527001E78D9 /* db-schema-8.sql */; }; FEE2D189268664FF007AEE74 /* db-schema-1.sql in Resources */ = {isa = PBXBuildFile; fileRef = FE168ACC1CCD197A003F8B26 /* db-schema-1.sql */; }; FEE2D18A268664FF007AEE74 /* db-schema-4.sql in Resources */ = {isa = PBXBuildFile; fileRef = FE8DD9CA221DBED80090F5AA /* db-schema-4.sql */; }; FEE2D18B268664FF007AEE74 /* db-schema-3.sql in Resources */ = {isa = PBXBuildFile; fileRef = FE4496C31F87911C009F649C /* db-schema-3.sql */; }; FEE2D18C268664FF007AEE74 /* db-schema-2.sql in Resources */ = {isa = PBXBuildFile; fileRef = FE2A0E8D1F74012D006ADF08 /* db-schema-2.sql */; }; FEE2D18D268664FF007AEE74 /* db-schema-6.sql in Resources */ = {isa = PBXBuildFile; fileRef = FEE9608B22F2F8950009B191 /* db-schema-6.sql */; }; FEE2D18E268664FF007AEE74 /* db-schema-7.sql in Resources */ = {isa = PBXBuildFile; fileRef = FEF19F0523474943005CFE9A /* db-schema-7.sql */; }; FEE2D18F268664FF007AEE74 /* db-schema-5.sql in Resources */ = {isa = PBXBuildFile; fileRef = FE719E732271AF88007CEEC9 /* db-schema-5.sql */; }; FEE2D19026866503007AEE74 /* db-schema-9.sql in Resources */ = {isa = PBXBuildFile; fileRef = FECEF29723B7B838007EC323 /* db-schema-9.sql */; }; FEE2D19126866503007AEE74 /* db-schema-13.sql in Resources */ = {isa = PBXBuildFile; fileRef = FEBC12F124C70DE000689475 /* db-schema-13.sql */; }; FEE2D19226866503007AEE74 /* db-schema-11.sql in Resources */ = {isa = PBXBuildFile; fileRef = FEC79198241BE89E007BE572 /* db-schema-11.sql */; }; FEE2D19326866503007AEE74 /* db-schema-10.sql in Resources */ = {isa = PBXBuildFile; fileRef = FE10BCF223FD4EF000E214F3 /* db-schema-10.sql */; }; FEE2D19426866503007AEE74 /* db-schema-14.sql in Resources */ = {isa = PBXBuildFile; fileRef = FEDE00C3266CE1680019CC1C /* db-schema-14.sql */; }; FEE2D19526866503007AEE74 /* db-schema-12.sql in Resources */ = {isa = PBXBuildFile; fileRef = FE3BA0BF24B61583000C80D4 /* db-schema-12.sql */; }; FEE49DCE2424C1F800900BBB /* ConversationLogController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE49DCD2424C1F800900BBB /* ConversationLogController.swift */; }; FEE49DD7242688E100900BBB /* ChannelViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE49DD6242688E100900BBB /* ChannelViewController.swift */; }; FEE9608A22F191980009B191 /* MucChatSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE9608922F191980009B191 /* MucChatSettingsViewController.swift */; }; FEF0967126D50ADC001C0454 /* UIColor_mix.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE137A4721F6464D006B7F7C /* UIColor_mix.swift */; }; FEF0967926D642EA001C0454 /* MediaHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEF0967826D642EA001C0454 /* MediaHelper.swift */; }; FEF0967A26D64347001C0454 /* ImageQuality.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0E3102253714420030F8C5 /* ImageQuality.swift */; }; FEF0967B26D64347001C0454 /* VideoQuality.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0E3107253714570030F8C5 /* VideoQuality.swift */; }; FEF0967C26D6437D001C0454 /* HTTPFileUploadHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0E30E32535BA520030F8C5 /* HTTPFileUploadHelper.swift */; }; FEF0967D26D6439E001C0454 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3E38892429364D00D3A8E8 /* UIImage.swift */; }; FEF0968226D7A9FB001C0454 /* ServerCertificateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D3B92662849E00CC883F /* ServerCertificateInfo.swift */; }; FEF0968326D7AA02001C0454 /* ServerCertificateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE11D3B92662849E00CC883F /* ServerCertificateInfo.swift */; }; FEF19F0223473B9E005CFE9A /* XmppServiceEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEF19F0123473B9E005CFE9A /* XmppServiceEventHandler.swift */; }; FEF19F0423473C06005CFE9A /* MessageEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEF19F0323473C06005CFE9A /* MessageEventHandler.swift */; }; FEF19F0A2347619D005CFE9A /* TasksQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEF19F092347619D005CFE9A /* TasksQueue.swift */; }; FEF19F0C23476466005CFE9A /* MucEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEF19F0B23476466005CFE9A /* MucEventHandler.swift */; }; FEF19F102348A046005CFE9A /* PresenceRosterEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEF19F0F2348A046005CFE9A /* PresenceRosterEventHandler.swift */; }; FEF19F122348A3B8005CFE9A /* AvatarEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEF19F112348A3B8005CFE9A /* AvatarEventHandler.swift */; }; FEF19F162348E781005CFE9A /* BaseChatViewControllerWithDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEF19F152348E781005CFE9A /* BaseChatViewControllerWithDataSource.swift */; }; FEF80DB21CDBBBFE005645A7 /* AccountSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEF80DB11CDBBBFE005645A7 /* AccountSettingsViewController.swift */; }; FEF8255524BA0AFE00820108 /* MessageTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEF8255424BA0AFE00820108 /* MessageTextView.swift */; }; FEFB63AD1F31E4EE00EFB3E7 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEFB63AC1F31E4EE00EFB3E7 /* MainTabBarController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ FE4DDF5B1F39E0B500A4CE5A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = FE94E5191CCBA74F00FAE755 /* Project object */; proxyType = 1; remoteGlobalIDString = FE4DDF521F39E0B500A4CE5A; remoteInfo = "SiskinIM - Share"; }; FE759FA62370ACA4001E78D9 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = FE94E5191CCBA74F00FAE755 /* Project object */; proxyType = 1; remoteGlobalIDString = FE759FA02370ACA4001E78D9; remoteInfo = NotificationService; }; FE759FCA2370B2A4001E78D9 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = FE94E5191CCBA74F00FAE755 /* Project object */; proxyType = 1; remoteGlobalIDString = FE759FC42370B2A4001E78D9; remoteInfo = Shared; }; FE759FD32370B2F2001E78D9 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = FE94E5191CCBA74F00FAE755 /* Project object */; proxyType = 1; remoteGlobalIDString = FE759FC42370B2A4001E78D9; remoteInfo = Shared; }; FE759FE42371C83D001E78D9 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = FE94E5191CCBA74F00FAE755 /* Project object */; proxyType = 1; remoteGlobalIDString = FE759FC42370B2A4001E78D9; remoteInfo = Shared; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ FE4DDF611F39E0B600A4CE5A /* Embed App Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( FE4DDF5D1F39E0B500A4CE5A /* Siskin IM - Share.appex in Embed App Extensions */, FE759FA82370ACA4001E78D9 /* NotificationService.appex in Embed App Extensions */, ); name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; }; FEF80DB71CDCC508005645A7 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 12; dstPath = ""; dstSubfolderSpec = 10; files = ( FE1908AB2584D6BC00CA049F /* OpenSSL.xcframework in Embed Frameworks */, FE759FCD2370B2A4001E78D9 /* Shared.framework in Embed Frameworks */, FE1A34A8258CD3EE0058B86A /* WebRTC.xcframework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ FE00157C2017617B00490340 /* StreamFeaturesCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamFeaturesCache.swift; sourceTree = ""; }; FE00157E2019090300490340 /* ExperimentalSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalSettingsViewController.swift; sourceTree = ""; }; FE01ADA81E224CF400FA7E65 /* SiskinPushNotificationsModule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiskinPushNotificationsModule.swift; sourceTree = ""; }; FE0691FC27F4D69F004E341E /* .bartycrouch.toml */ = {isa = PBXFileReference; lastKnownFileType = text; path = .bartycrouch.toml; sourceTree = ""; }; FE0691FE27F4D84B004E341E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = localization/en.lproj/Groupchat.strings; sourceTree = ""; }; FE06920027F4D8D8004E341E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = localization/en.lproj/VoIP.strings; sourceTree = ""; }; FE06920227F4D910004E341E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = localization/en.lproj/Main.strings; sourceTree = ""; }; FE06920427F4D948004E341E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = localization/en.lproj/Info.strings; sourceTree = ""; }; FE06920627F4D97E004E341E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = localization/en.lproj/Settings.strings; sourceTree = ""; }; FE06920827F4D9BE004E341E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = localization/en.lproj/Account.strings; sourceTree = ""; }; FE06920A27F4D9D6004E341E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = localization/en.lproj/Conversation.strings; sourceTree = ""; }; FE06920C27F4DA12004E341E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = localization/en.lproj/MIX.strings; sourceTree = ""; }; FE06920D27F4DA58004E341E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; FE0AE0B326A9DD6D0010D2E2 /* SetAccountSettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetAccountSettingsController.swift; sourceTree = ""; }; FE0AE0B526AED7870010D2E2 /* GetInTouchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetInTouchViewController.swift; sourceTree = ""; }; FE0AE13426B575820010D2E2 /* MeetController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetController.swift; sourceTree = ""; }; FE0AE13626B58C4F0010D2E2 /* MeetManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetManager.swift; sourceTree = ""; }; FE0AE13826B7EA770010D2E2 /* MeetEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetEventHandler.swift; sourceTree = ""; }; FE0E30DE2535B9D20030F8C5 /* BaseChatViewController+Share.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseChatViewController+Share.swift"; sourceTree = ""; }; FE0E30E32535BA520030F8C5 /* HTTPFileUploadHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPFileUploadHelper.swift; sourceTree = ""; }; FE0E30E82535BA910030F8C5 /* BaseChatViewController+ShareMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseChatViewController+ShareMedia.swift"; sourceTree = ""; }; FE0E30F02535BB530030F8C5 /* BaseChatViewController+ShareFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseChatViewController+ShareFile.swift"; sourceTree = ""; }; FE0E30FD253714050030F8C5 /* MediaHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaHelper.swift; sourceTree = ""; }; FE0E3102253714420030F8C5 /* ImageQuality.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageQuality.swift; sourceTree = ""; }; FE0E3107253714570030F8C5 /* VideoQuality.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoQuality.swift; sourceTree = ""; }; FE0E31112537288A0030F8C5 /* MediaSettingsVIewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSettingsVIewController.swift; sourceTree = ""; }; FE10BCF223FD4EF000E214F3 /* db-schema-10.sql */ = {isa = PBXFileReference; lastKnownFileType = text; path = "db-schema-10.sql"; sourceTree = ""; }; FE11D2E926616F0100CC883F /* Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = ""; }; FE11D2F12662394B00CC883F /* DisplayableIdProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayableIdProtocol.swift; sourceTree = ""; }; FE11D2F626623A5900CC883F /* ConversationBase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationBase.swift; sourceTree = ""; }; FE11D2F726623A5900CC883F /* Channel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Channel.swift; sourceTree = ""; }; FE11D2F826623A5900CC883F /* Room.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Room.swift; sourceTree = ""; }; FE11D2F926623A5900CC883F /* Conversation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Conversation.swift; sourceTree = ""; }; FE11D2FA26623A5900CC883F /* Chat.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Chat.swift; sourceTree = ""; }; FE11D2FB26623A5900CC883F /* AccountConversations.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountConversations.swift; sourceTree = ""; }; FE11D30526623A6200CC883F /* ConversationEntryEncryption.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationEntryEncryption.swift; sourceTree = ""; }; FE11D30726623A6200CC883F /* ConversationAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationAttachment.swift; sourceTree = ""; }; FE11D30826623A6200CC883F /* AppendixProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppendixProtocol.swift; sourceTree = ""; }; FE11D30926623A6200CC883F /* ConversationEntryRecipient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationEntryRecipient.swift; sourceTree = ""; }; FE11D30A26623A6200CC883F /* ConversationEntrySender.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationEntrySender.swift; sourceTree = ""; }; FE11D30B26623A6200CC883F /* ConversationInvitation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationInvitation.swift; sourceTree = ""; }; FE11D30C26623A6200CC883F /* ConversationEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationEntry.swift; sourceTree = ""; }; FE11D30D26623A6300CC883F /* ConversationEntryState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationEntryState.swift; sourceTree = ""; }; FE11D30E26623A6300CC883F /* ConversationKey.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationKey.swift; sourceTree = ""; }; FE11D32226623C2F00CC883F /* DBChatMarkersStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBChatMarkersStore.swift; sourceTree = ""; }; FE11D32726623C7700CC883F /* DBChatStore+ChannelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DBChatStore+ChannelStore.swift"; sourceTree = ""; }; FE11D32C26623CA300CC883F /* DBChatStore+ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DBChatStore+ChatStore.swift"; sourceTree = ""; }; FE11D33126623CD000CC883F /* DBChatStore+RoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DBChatStore+RoomStore.swift"; sourceTree = ""; }; FE11D33626623D1E00CC883F /* DBVCardStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBVCardStore.swift; sourceTree = ""; }; FE11D39026623E9D00CC883F /* ConversationDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationDataSource.swift; sourceTree = ""; }; FE11D39C26623F2300CC883F /* Array+IndexChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+IndexChanges.swift"; sourceTree = ""; }; FE11D3B2266241BD00CC883F /* Publisher+ThrottledSink.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Publisher+ThrottledSink.swift"; sourceTree = ""; }; FE11D3B3266241BD00CC883F /* Publisher+OnlyGetter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Publisher+OnlyGetter.swift"; sourceTree = ""; }; FE11D3B92662849E00CC883F /* ServerCertificateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerCertificateInfo.swift; sourceTree = ""; }; FE11D3BE2662852A00CC883F /* XMPPClient_extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XMPPClient_extension.swift; sourceTree = ""; }; FE11D3C42662AB8900CC883F /* SSLProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSLProcessor.swift; sourceTree = ""; }; FE11D3C52662AB8900CC883F /* SSLContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSLContext.swift; sourceTree = ""; }; FE11D3C62662AB8900CC883F /* SSLCertificate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSLCertificate.swift; sourceTree = ""; }; FE11D3D02662B91E00CC883F /* PresenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceStore.swift; sourceTree = ""; }; FE11D3D926639D1600CC883F /* ContactManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactManager.swift; sourceTree = ""; }; FE11D3E526639DEA00CC883F /* VCardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCardManager.swift; sourceTree = ""; }; FE137A4721F6464D006B7F7C /* UIColor_mix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor_mix.swift; sourceTree = ""; }; FE137A4B21F75660006B7F7C /* ChatBottomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBottomView.swift; sourceTree = ""; }; FE168ACC1CCD197A003F8B26 /* db-schema-1.sql */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "db-schema-1.sql"; sourceTree = ""; }; FE17808C23EB4C7F00A1EA76 /* AccountQRCodeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountQRCodeController.swift; sourceTree = ""; }; FE1908962584D69400CA049F /* OpenSSL.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = OpenSSL.xcframework; sourceTree = ""; }; FE1A07472525EDD4004F38A0 /* ExternalServiceDiscovery_Service_extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalServiceDiscovery_Service_extension.swift; sourceTree = ""; }; FE1A349F258CD3E10058B86A /* WebRTC.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = WebRTC.xcframework; sourceTree = ""; }; FE1AC8F6216B8AB700D4CDAB /* NewFeaturesDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewFeaturesDetector.swift; sourceTree = ""; }; FE1DCCA11EA52CE200850563 /* DataFormController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataFormController.swift; sourceTree = ""; }; FE1EE9D127862A5F000FA599 /* HttpFileUploadModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpFileUploadModule.swift; sourceTree = ""; }; FE1EE9D527903F58000FA599 /* ChannelSelectNewOwnerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelSelectNewOwnerViewController.swift; sourceTree = ""; }; FE1FA7152663AFFD0010333E /* CurrentDatePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentDatePublisher.swift; sourceTree = ""; }; FE2332DA242B9C2300008ED4 /* ChannelInviteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelInviteController.swift; sourceTree = ""; }; FE2332E0242CCDB400008ED4 /* InvitationChatTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitationChatTableViewCell.swift; sourceTree = ""; }; FE2332E2242CE8D600008ED4 /* ChannelBlockedUsersController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelBlockedUsersController.swift; sourceTree = ""; }; FE233CD421E6846E00099281 /* CameraPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPreviewView.swift; sourceTree = ""; }; FE233CDC21EA062E00099281 /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = ""; }; FE258EA91F3B8BC90042CED9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; FE2809802167CE18002F5BD0 /* server_features_list.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = server_features_list.xml; sourceTree = ""; }; FE2809822167CF1B002F5BD0 /* ServerFeaturesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerFeaturesViewController.swift; sourceTree = ""; }; FE2A00BA27F89BF500AF0152 /* Publisher+ThrottleFixed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+ThrottleFixed.swift"; sourceTree = ""; }; FE2A00BD28048D6C00AF0152 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = localization/en.lproj/MainInterface.strings; sourceTree = ""; }; FE2A0E8D1F74012D006ADF08 /* db-schema-2.sql */ = {isa = PBXFileReference; lastKnownFileType = text; path = "db-schema-2.sql"; sourceTree = ""; }; FE2D481924505F1600C13CE5 /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; FE2D481B24518C2800C13CE5 /* RTCCameraVideoCapturer_Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCCameraVideoCapturer_Format.swift; sourceTree = ""; }; FE31291B222C0D1500A92863 /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = ""; }; FE31DDE3201261A200C2AB1D /* DNSSrvDiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DNSSrvDiskCache.swift; sourceTree = ""; }; FE36B3C721FA52E000D1F037 /* EmptyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyViewController.swift; sourceTree = ""; }; FE3A427426DA70B700D914CE /* ChatViewInputBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewInputBar.swift; sourceTree = ""; }; FE3A45CE1CE49D3300C36264 /* RosterItemEditViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RosterItemEditViewController.swift; sourceTree = ""; }; FE3BA0BF24B61583000C80D4 /* db-schema-12.sql */ = {isa = PBXFileReference; lastKnownFileType = text; path = "db-schema-12.sql"; sourceTree = ""; }; FE3DCCED1FE18334008B6C8B /* CertificateErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateErrorAlert.swift; sourceTree = ""; }; FE3E3879242765E700D3A8E8 /* MixEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixEventHandler.swift; sourceTree = ""; }; FE3E387D2427A09A00D3A8E8 /* ChannelSelectToJoinViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelSelectToJoinViewController.swift; sourceTree = ""; }; FE3E387F2427B8A900D3A8E8 /* ChannelsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelsHelper.swift; sourceTree = ""; }; FE3E38812427BF8600D3A8E8 /* ChannelSelectAccountAndComponentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelSelectAccountAndComponentController.swift; sourceTree = ""; }; FE3E38832427E34300D3A8E8 /* ChannelJoinViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelJoinViewController.swift; sourceTree = ""; }; FE3E38852428C21100D3A8E8 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; FE3E38872428D9DB00D3A8E8 /* ChannelCreateViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelCreateViewController.swift; sourceTree = ""; }; FE3E38892429364D00D3A8E8 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; FE3E388B2429FAC500D3A8E8 /* ChannelSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelSettingsViewController.swift; sourceTree = ""; }; FE3E388D242A251E00D3A8E8 /* ChannelEditInfoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditInfoController.swift; sourceTree = ""; }; FE3E388F242A98C000D3A8E8 /* ChannelParticipantsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelParticipantsController.swift; sourceTree = ""; }; FE4071E321E2605900F09B58 /* VideoCallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCallController.swift; sourceTree = ""; }; FE4071E721E2653700F09B58 /* RoundButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundButton.swift; sourceTree = ""; }; FE43E43723BF3DE80079BD9B /* ChatAttachementsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAttachementsController.swift; sourceTree = ""; }; FE4496C31F87911C009F649C /* db-schema-3.sql */ = {isa = PBXFileReference; lastKnownFileType = text; path = "db-schema-3.sql"; sourceTree = ""; }; FE4744062877120E00B28980 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; FE4744072877121100B28980 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; FE4A94A926679D65000A96E5 /* EnumTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumTableViewCell.swift; sourceTree = ""; }; FE4DDF531F39E0B500A4CE5A /* Siskin IM - Share.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Siskin IM - Share.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; FE4DDF551F39E0B500A4CE5A /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; FE4DDF581F39E0B500A4CE5A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = localization/Base.lproj/MainInterface.storyboard; sourceTree = ""; }; FE4DDF5A1F39E0B500A4CE5A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FE4DDF621F3A12AD00A4CE5A /* SiskinIM - Share.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SiskinIM - Share.entitlements"; sourceTree = ""; }; FE5079EF1CD3CA91001A015C /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; FE5079FE1CDB7B3B001A015C /* ChatsListTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatsListTableViewCell.swift; sourceTree = ""; }; FE5079FF1CDB7B3B001A015C /* ChatsListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatsListViewController.swift; sourceTree = ""; }; FE507A001CDB7B3B001A015C /* ChatTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatTableViewCell.swift; sourceTree = ""; }; FE507A011CDB7B3B001A015C /* ChatViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatViewController.swift; sourceTree = ""; }; FE507A031CDB7B3B001A015C /* DBChatHistoryStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DBChatHistoryStore.swift; sourceTree = ""; }; FE507A041CDB7B3B001A015C /* DBChatStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DBChatStore.swift; sourceTree = ""; }; FE507A061CDB7B3B001A015C /* DBRosterStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DBRosterStore.swift; sourceTree = ""; }; FE507A081CDB7B3B001A015C /* RosterItemTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RosterItemTableViewCell.swift; sourceTree = ""; }; FE507A091CDB7B3B001A015C /* RosterViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RosterViewController.swift; sourceTree = ""; }; FE507A0B1CDB7B3B001A015C /* AccountTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountTableViewCell.swift; sourceTree = ""; }; FE507A0C1CDB7B3B001A015C /* AddAccountController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddAccountController.swift; sourceTree = ""; }; FE507A0D1CDB7B3B001A015C /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; FE507A0F1CDB7B3B001A015C /* AvatarStatusView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarStatusView.swift; sourceTree = ""; }; FE507A101CDB7B3B001A015C /* CustomTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomTabBarController.swift; sourceTree = ""; }; FE507A111CDB7B3B001A015C /* GlobalSplitViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlobalSplitViewController.swift; sourceTree = ""; }; FE507A131CDB7B3B001A015C /* AccountManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountManager.swift; sourceTree = ""; }; FE507A141CDB7B3B001A015C /* AvatarManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarManager.swift; sourceTree = ""; }; FE60F29B1ED48B470030D411 /* libxml2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libxml2.tbd; path = usr/lib/libxml2.tbd; sourceTree = SDKROOT; }; FE64445D281314ED002D6E8E /* BookmarksController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksController.swift; sourceTree = ""; }; FE64445F28131538002D6E8E /* BookmarkViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkViewCell.swift; sourceTree = ""; }; FE644461281334ED002D6E8E /* BookmarkItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkItem.swift; sourceTree = ""; }; FE65455F1E9E7B85006A14AC /* RegisterAccountController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RegisterAccountController.swift; sourceTree = ""; }; FE6545611E9E7FDE006A14AC /* AccountDomainTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountDomainTableViewCell.swift; sourceTree = ""; }; FE6545631E9E8B67006A14AC /* ServerSelectorTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerSelectorTableViewCell.swift; sourceTree = ""; }; FE65D62722E9F8EB0065DEA5 /* Markdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Markdown.swift; sourceTree = ""; }; FE719E732271AF88007CEEC9 /* db-schema-5.sql */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "db-schema-5.sql"; sourceTree = ""; }; FE719E752271B2BA007CEEC9 /* OpenSSL_AES_GCM_Engine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSSL_AES_GCM_Engine.swift; sourceTree = ""; }; FE719E772271B439007CEEC9 /* MessageEncryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageEncryption.swift; sourceTree = ""; }; FE719E7B22730DC3007CEEC9 /* OMEMOIdentityTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OMEMOIdentityTableViewCell.swift; sourceTree = ""; }; FE719E7D2274D20D007CEEC9 /* OMEMOFingerprintsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OMEMOFingerprintsController.swift; sourceTree = ""; }; FE74D50F234A4E1F001A925B /* ChatTableViewSystemCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTableViewSystemCell.swift; sourceTree = ""; }; FE759FA12370ACA4001E78D9 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; FE759FA32370ACA4001E78D9 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; FE759FA52370ACA4001E78D9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FE759FC52370B2A4001E78D9 /* Shared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Shared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; FE759FC72370B2A4001E78D9 /* Shared.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Shared.h; sourceTree = ""; }; FE759FC82370B2A4001E78D9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FE759FDA2370B384001E78D9 /* Cipher+AES.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cipher+AES.swift"; sourceTree = ""; }; FE759FDD2371988B001E78D9 /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk/usr/lib/libsqlite3.tbd; sourceTree = DEVELOPER_DIR; }; FE759FE12371C79E001E78D9 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; FE759FE72371C966001E78D9 /* libxml2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libxml2.tbd; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk/usr/lib/libxml2.tbd; sourceTree = DEVELOPER_DIR; }; FE759FF423741527001E78D9 /* db-schema-8.sql */ = {isa = PBXFileReference; lastKnownFileType = text; path = "db-schema-8.sql"; sourceTree = ""; }; FE759FF823742AC1001E78D9 /* NotificationCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCategory.swift; sourceTree = ""; }; FE759FFA23742C48001E78D9 /* NotificationCenterDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenterDelegate.swift; sourceTree = ""; }; FE75A00223743A5C001E78D9 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; FE75A004237475CD001E78D9 /* MainNotificationManagerProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNotificationManagerProvider.swift; sourceTree = ""; }; FE75A007237585DC001E78D9 /* NotificationEncryptionKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationEncryptionKeys.swift; sourceTree = ""; }; FE75A00E2375F324001E78D9 /* PushEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushEventHandler.swift; sourceTree = ""; }; FE75A0112376E73C001E78D9 /* SiskinPushNotificationsModuleProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiskinPushNotificationsModuleProvider.swift; sourceTree = ""; }; FE7D293323B919FF001A877D /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; FE7D293523BB5E0A001A877D /* LinkPreviewChatTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewChatTableViewCell.swift; sourceTree = ""; }; FE7F645A1D281B1C00B9DF56 /* DBCapabilitiesCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DBCapabilitiesCache.swift; sourceTree = ""; }; FE7F9302200FD5AC004C6195 /* AccountManagerScramSaltedPasswordCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManagerScramSaltedPasswordCache.swift; sourceTree = ""; }; FE80BDA91D92974C001914B0 /* SiskinIM.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SiskinIM.entitlements; sourceTree = ""; }; FE80BDAA1D953FC4001914B0 /* SetupViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetupViewController.swift; sourceTree = ""; }; FE82A728268C83020049C844 /* ChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartView.swift; sourceTree = ""; }; FE82A72A268CC0740049C844 /* DeviceMemoryUsageTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceMemoryUsageTableViewCell.swift; sourceTree = ""; }; FE86C4481F7BFF93009E3CB8 /* SiskinIM-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "SiskinIM-Bridging-Header.h"; sourceTree = ""; }; FE8885EB26BAEAAE0095E25E /* MultiContactSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiContactSelectionView.swift; sourceTree = ""; }; FE8885ED26BC6B570095E25E /* CreateMeetingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateMeetingViewController.swift; sourceTree = ""; }; FE8DD9C4221B153A0090F5AA /* InviteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteViewController.swift; sourceTree = ""; }; FE8DD9C6221B15DC0090F5AA /* AbstractRosterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbstractRosterViewController.swift; sourceTree = ""; }; FE8DD9CA221DBED80090F5AA /* db-schema-4.sql */ = {isa = PBXFileReference; lastKnownFileType = text; path = "db-schema-4.sql"; sourceTree = ""; }; FE94E5211CCBA74F00FAE755 /* Siskin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Siskin.app; sourceTree = BUILT_PRODUCTS_DIR; }; FE94E5241CCBA74F00FAE755 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; FE94E52B1CCBA74F00FAE755 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = localization/Base.lproj/Main.storyboard; sourceTree = ""; }; FE94E52D1CCBA74F00FAE755 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; FE94E5301CCBA74F00FAE755 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = localization/Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; FE94E5321CCBA74F00FAE755 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FE94E55D1CCCC14E00FAE755 /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; FE96259F1D9AE7CB00D07118 /* RosterProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RosterProvider.swift; sourceTree = ""; }; FE9D4DA226BE9EFD00DD295A /* foreman.mp4 */ = {isa = PBXFileReference; lastKnownFileType = file; path = foreman.mp4; sourceTree = ""; }; FE9D4DA426C1375300DD295A /* InviteToMeetingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteToMeetingViewController.swift; sourceTree = ""; }; FE9D4DA626C7E87C00DD295A /* AudioSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSession.swift; sourceTree = ""; }; FE9E136C1F25F5F7005C0EE5 /* ChatSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatSettingsViewController.swift; sourceTree = ""; }; FE9E136E1F26049A005C0EE5 /* NotificationSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationSettingsViewController.swift; sourceTree = ""; }; FE9E13701F2606E9005C0EE5 /* ContactsSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactsSettingsViewController.swift; sourceTree = ""; }; FE9E13721F260B33005C0EE5 /* StepperTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StepperTableViewCell.swift; sourceTree = ""; }; FE9EA16A23BF9DB2008C401A /* ChatAttachementsCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAttachementsCellView.swift; sourceTree = ""; }; FEA303AA24694447004A3B3E /* VCardAvatarEditCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCardAvatarEditCell.swift; sourceTree = ""; }; FEA303AC24696604004A3B3E /* VCardTextEditCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCardTextEditCell.swift; sourceTree = ""; }; FEA308CF1F27A063002EF4C0 /* NavigationControllerWrappingSegue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationControllerWrappingSegue.swift; sourceTree = ""; }; FEA518A32661593B00523EF2 /* Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = ""; }; FEA518B82661618D00523EF2 /* DatabaseMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseMigrator.swift; sourceTree = ""; }; FEA6DCB7271890090079DBEC /* ShareLocationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLocationController.swift; sourceTree = ""; }; FEA6DCB92719DF230079DBEC /* ShareLocationSearchResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLocationSearchResultsController.swift; sourceTree = ""; }; FEA6DCBB271A01770079DBEC /* LocationChatTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChatTableViewCell.swift; sourceTree = ""; }; FEA7BF5A21E50C5800D9E36C /* JingleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JingleManager.swift; sourceTree = ""; }; FEA7BF5C21E50CAB00D9E36C /* JingleManager_Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JingleManager_Session.swift; sourceTree = ""; }; FEA8D65C1F2F6AF60077C12F /* VCardEntryTypeAwareTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VCardEntryTypeAwareTableViewCell.swift; sourceTree = ""; }; FEA8D6611F30F54B0077C12F /* XmppService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XmppService.swift; sourceTree = ""; }; FEAC71781CECE50400ABABEF /* MucChatOccupantsTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MucChatOccupantsTableViewController.swift; sourceTree = ""; }; FEAC717A1CECE70100ABABEF /* MucChatOccupantsTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MucChatOccupantsTableViewCell.swift; sourceTree = ""; }; FEB5EC9C1F6AE448007FE0E7 /* BaseChatViewControllerWithDataSourceContextMenuAndToolbar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseChatViewControllerWithDataSourceContextMenuAndToolbar.swift; sourceTree = ""; }; FEB62C4F1DA80956001500D5 /* AvatarStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarStore.swift; sourceTree = ""; }; FEBA82FA26F76A8000347D89 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = localization/Base.lproj/VoIP.storyboard; sourceTree = ""; }; FEBA82FD26F76A8700347D89 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = localization/Base.lproj/Groupchat.storyboard; sourceTree = ""; }; FEBA830026F76A8D00347D89 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = localization/Base.lproj/Info.storyboard; sourceTree = ""; }; FEBA830326F76A9300347D89 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = localization/Base.lproj/Settings.storyboard; sourceTree = ""; }; FEBA830626F76A9800347D89 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = localization/Base.lproj/Account.storyboard; sourceTree = ""; }; FEBA830926F76A9C00347D89 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = localization/Base.lproj/Conversation.storyboard; sourceTree = ""; }; FEBA830C26F76AA200347D89 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = localization/Base.lproj/MIX.storyboard; sourceTree = ""; }; FEBA830E26F76ADA00347D89 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = localization/pl.lproj/LaunchScreen.strings; sourceTree = ""; }; FEBA830F26F76ADA00347D89 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = localization/pl.lproj/Main.strings; sourceTree = ""; }; FEBA831026F76ADA00347D89 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = localization/pl.lproj/VoIP.strings; sourceTree = ""; }; FEBA831126F76ADA00347D89 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = localization/pl.lproj/Groupchat.strings; sourceTree = ""; }; FEBA831226F76B0800347D89 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = localization/pl.lproj/Info.strings; sourceTree = ""; }; FEBA831326F76B0800347D89 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = localization/pl.lproj/Settings.strings; sourceTree = ""; }; FEBA831426F76B0800347D89 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = localization/pl.lproj/Account.strings; sourceTree = ""; }; FEBA831526F76B0800347D89 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = localization/pl.lproj/Conversation.strings; sourceTree = ""; }; FEBA831626F76B0800347D89 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = localization/pl.lproj/MIX.strings; sourceTree = ""; }; FEBA831726F76B0800347D89 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = localization/pl.lproj/MainInterface.strings; sourceTree = ""; }; FEBA835526F8DF1300347D89 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; FEBC12F124C70DE000689475 /* db-schema-13.sql */ = {isa = PBXFileReference; lastKnownFileType = text; path = "db-schema-13.sql"; sourceTree = ""; }; FEBC12F324C70E2900689475 /* DBChatHistorySyncStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBChatHistorySyncStore.swift; sourceTree = ""; }; FEC514251CEB74F8003AF765 /* BaseChatViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseChatViewController.swift; sourceTree = ""; }; FEC514271CEB82E9003AF765 /* MucChatViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MucChatViewController.swift; sourceTree = ""; }; FEC79194241ABEF4007BE572 /* MessageState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageState.swift; sourceTree = ""; }; FEC79198241BE89E007BE572 /* db-schema-11.sql */ = {isa = PBXFileReference; lastKnownFileType = text; path = "db-schema-11.sql"; sourceTree = ""; }; FECEF29323B7933A007EC323 /* MetadataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataCache.swift; sourceTree = ""; }; FECEF29523B7B076007EC323 /* DownloadStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadStore.swift; sourceTree = ""; }; FECEF29723B7B838007EC323 /* db-schema-9.sql */ = {isa = PBXFileReference; lastKnownFileType = text; path = "db-schema-9.sql"; sourceTree = ""; }; FECEF29923B7BBD3007EC323 /* BaseChatTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseChatTableViewCell.swift; sourceTree = ""; }; FECEF29C23B7C36F007EC323 /* AttachmentChatTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentChatTableViewCell.swift; sourceTree = ""; }; FED353732270BBA500B69C53 /* TigaseSwiftOMEMO.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TigaseSwiftOMEMO.framework; sourceTree = BUILT_PRODUCTS_DIR; }; FED353882270C1D000B69C53 /* DBOMEMOStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBOMEMOStore.swift; sourceTree = ""; }; FED4043F283E9C5800C91BCF /* AccountConnectivitySettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConnectivitySettingsViewController.swift; sourceTree = ""; }; FEDC6789238A9F16005C0FAB /* BlockedEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedEventHandler.swift; sourceTree = ""; }; FEDC678D238B03C1005C0FAB /* AppStoryboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoryboard.swift; sourceTree = ""; }; FEDC678F238B05E4005C0FAB /* BlockedContactsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedContactsController.swift; sourceTree = ""; }; FEDCBF661D9C3EE700AE9129 /* RosterProviderFlat.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RosterProviderFlat.swift; sourceTree = ""; }; FEDCBF681D9C53BA00AE9129 /* RosterProviderGrouped.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RosterProviderGrouped.swift; sourceTree = ""; }; FEDE0092266BCA450019CC1C /* ConversationNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationNotifications.swift; sourceTree = ""; }; FEDE0097266BCD7A0019CC1C /* ConversationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationType.swift; sourceTree = ""; }; FEDE009C266BE3BD0019CC1C /* NotificationsManagerHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManagerHelper.swift; sourceTree = ""; }; FEDE00AF266CC7850019CC1C /* InvitationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitationsManager.swift; sourceTree = ""; }; FEDE00C3266CE1680019CC1C /* db-schema-14.sql */ = {isa = PBXFileReference; lastKnownFileType = text; path = "db-schema-14.sql"; sourceTree = ""; }; FEDE00C8266CEF270019CC1C /* ChatTableViewMarkerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTableViewMarkerCell.swift; sourceTree = ""; }; FEDE93861D07564F00CA60A9 /* SwitchTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwitchTableViewCell.swift; sourceTree = ""; }; FEDE93881D081C3D00CA60A9 /* Settings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; FEDE938B1D08AFE800CA60A9 /* VCardEditViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VCardEditViewController.swift; sourceTree = ""; }; FEDE938F1D09BB8300CA60A9 /* VCardEditPhoneTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VCardEditPhoneTableViewCell.swift; sourceTree = ""; }; FEDE93911D09E74100CA60A9 /* VCardEditAddressTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VCardEditAddressTableViewCell.swift; sourceTree = ""; }; FEDE93931D0AC01200CA60A9 /* VCardEditEmailTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VCardEditEmailTableViewCell.swift; sourceTree = ""; }; FEDE93961D0C202600CA60A9 /* ContactViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactViewController.swift; sourceTree = ""; }; FEDE93981D0C207100CA60A9 /* ContactBasicTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactBasicTableViewCell.swift; sourceTree = ""; }; FEDE939A1D0C38B000CA60A9 /* ContactFormTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactFormTableViewCell.swift; sourceTree = ""; }; FEE097611F1FCE1800B1CEAB /* TablePicketViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TablePicketViewController.swift; sourceTree = ""; }; FEE49DCD2424C1F800900BBB /* ConversationLogController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationLogController.swift; sourceTree = ""; }; FEE49DD6242688E100900BBB /* ChannelViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelViewController.swift; sourceTree = ""; }; FEE9608922F191980009B191 /* MucChatSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MucChatSettingsViewController.swift; sourceTree = ""; }; FEE9608B22F2F8950009B191 /* db-schema-6.sql */ = {isa = PBXFileReference; lastKnownFileType = text; path = "db-schema-6.sql"; sourceTree = ""; }; FEF0967826D642EA001C0454 /* MediaHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaHelper.swift; sourceTree = ""; }; FEF19F0123473B9E005CFE9A /* XmppServiceEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XmppServiceEventHandler.swift; sourceTree = ""; }; FEF19F0323473C06005CFE9A /* MessageEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageEventHandler.swift; sourceTree = ""; }; FEF19F0523474943005CFE9A /* db-schema-7.sql */ = {isa = PBXFileReference; lastKnownFileType = text; path = "db-schema-7.sql"; sourceTree = ""; }; FEF19F092347619D005CFE9A /* TasksQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TasksQueue.swift; sourceTree = ""; }; FEF19F0B23476466005CFE9A /* MucEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MucEventHandler.swift; sourceTree = ""; }; FEF19F0F2348A046005CFE9A /* PresenceRosterEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceRosterEventHandler.swift; sourceTree = ""; }; FEF19F112348A3B8005CFE9A /* AvatarEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarEventHandler.swift; sourceTree = ""; }; FEF19F152348E781005CFE9A /* BaseChatViewControllerWithDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseChatViewControllerWithDataSource.swift; sourceTree = ""; }; FEF80DB11CDBBBFE005645A7 /* AccountSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountSettingsViewController.swift; sourceTree = ""; }; FEF8255424BA0AFE00820108 /* MessageTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextView.swift; sourceTree = ""; }; FEF97CE728770E5C008CF411 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = localization/de.lproj/LaunchScreen.strings; sourceTree = ""; }; FEF97CE828770E5C008CF411 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = localization/de.lproj/Main.strings; sourceTree = ""; }; FEF97CE928770E5C008CF411 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = localization/de.lproj/VoIP.strings; sourceTree = ""; }; FEF97CEA28770E5C008CF411 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = localization/de.lproj/Groupchat.strings; sourceTree = ""; }; FEF97CEB28770E5C008CF411 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = localization/de.lproj/Info.strings; sourceTree = ""; }; FEF97CEC28770E5C008CF411 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = localization/de.lproj/Settings.strings; sourceTree = ""; }; FEF97CED28770E5C008CF411 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = localization/de.lproj/Account.strings; sourceTree = ""; }; FEF97CEE28770E5C008CF411 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = localization/de.lproj/Conversation.strings; sourceTree = ""; }; FEF97CEF28770E5C008CF411 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = localization/de.lproj/MIX.strings; sourceTree = ""; }; FEF97CF028770E60008CF411 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = localization/de.lproj/MainInterface.strings; sourceTree = ""; }; FEF97CF128770E7F008CF411 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = localization/es.lproj/LaunchScreen.strings; sourceTree = ""; }; FEF97CF228770E7F008CF411 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = localization/es.lproj/Main.strings; sourceTree = ""; }; FEF97CF328770E80008CF411 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = localization/es.lproj/VoIP.strings; sourceTree = ""; }; FEF97CF428770E80008CF411 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = localization/es.lproj/Groupchat.strings; sourceTree = ""; }; FEF97CF528770E80008CF411 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = localization/es.lproj/Info.strings; sourceTree = ""; }; FEF97CF628770E80008CF411 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = localization/es.lproj/Settings.strings; sourceTree = ""; }; FEF97CF728770E80008CF411 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = localization/es.lproj/Account.strings; sourceTree = ""; }; FEF97CF828770E80008CF411 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = localization/es.lproj/Conversation.strings; sourceTree = ""; }; FEF97CF928770E80008CF411 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = localization/es.lproj/MIX.strings; sourceTree = ""; }; FEF97CFA28770E83008CF411 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = localization/es.lproj/MainInterface.strings; sourceTree = ""; }; FEFB63AC1F31E4EE00EFB3E7 /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ FE4DDF501F39E0B500A4CE5A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( FE759FE22371C83D001E78D9 /* Shared.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; FE759F9E2370ACA4001E78D9 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( FE759FD12370B2F2001E78D9 /* Shared.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; FE759FC22370B2A4001E78D9 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( FE759FDE2371989B001E78D9 /* libsqlite3.tbd in Frameworks */, FEA518C2266165A100523EF2 /* TigaseLogging in Frameworks */, FEA518B1266159B900523EF2 /* TigaseSQLite3 in Frameworks */, FE392E03289AF47D006AA914 /* MartinOMEMO in Frameworks */, FE759FE82371C972001E78D9 /* libxml2.tbd in Frameworks */, FE1908B72584D70300CA049F /* OpenSSL.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; FE94E51E1CCBA74F00FAE755 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( FE1A34A7258CD3EE0058B86A /* WebRTC.xcframework in Frameworks */, FE5079F01CD3CA91001A015C /* Security.framework in Frameworks */, FE759FCC2370B2A4001E78D9 /* Shared.framework in Frameworks */, FE1908AA2584D6BC00CA049F /* OpenSSL.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ FE01ADA11E214CEA00FA7E65 /* xmpp */ = { isa = PBXGroup; children = ( FE01ADA81E224CF400FA7E65 /* SiskinPushNotificationsModule.swift */, FE1EE9D127862A5F000FA599 /* HttpFileUploadModule.swift */, ); path = xmpp; sourceTree = ""; }; FE11D2EE2662392200CC883F /* conversations */ = { isa = PBXGroup; children = ( FE11D2FB26623A5900CC883F /* AccountConversations.swift */, FE11D2F726623A5900CC883F /* Channel.swift */, FE11D2FA26623A5900CC883F /* Chat.swift */, FE11D2F926623A5900CC883F /* Conversation.swift */, FE11D2F626623A5900CC883F /* ConversationBase.swift */, FE11D2F826623A5900CC883F /* Room.swift */, ); path = conversations; sourceTree = ""; }; FE11D2EF2662392B00CC883F /* model */ = { isa = PBXGroup; children = ( FE11D2F02662393900CC883F /* history */, FE11D2EE2662392200CC883F /* conversations */, FE11D2F12662394B00CC883F /* DisplayableIdProtocol.swift */, ); path = model; sourceTree = ""; }; FE11D2F02662393900CC883F /* history */ = { isa = PBXGroup; children = ( FE11D30826623A6200CC883F /* AppendixProtocol.swift */, FE11D30726623A6200CC883F /* ConversationAttachment.swift */, FE11D30C26623A6200CC883F /* ConversationEntry.swift */, FE11D30526623A6200CC883F /* ConversationEntryEncryption.swift */, FE11D30926623A6200CC883F /* ConversationEntryRecipient.swift */, FE11D30A26623A6200CC883F /* ConversationEntrySender.swift */, FE11D30D26623A6300CC883F /* ConversationEntryState.swift */, FE11D30B26623A6200CC883F /* ConversationInvitation.swift */, FE11D30E26623A6300CC883F /* ConversationKey.swift */, ); path = history; sourceTree = ""; }; FE11D38F26623E8200CC883F /* conversation */ = { isa = PBXGroup; children = ( FE11D39026623E9D00CC883F /* ConversationDataSource.swift */, FEE49DCD2424C1F800900BBB /* ConversationLogController.swift */, FECEF29923B7BBD3007EC323 /* BaseChatTableViewCell.swift */, FE74D50F234A4E1F001A925B /* ChatTableViewSystemCell.swift */, FE507A001CDB7B3B001A015C /* ChatTableViewCell.swift */, FE7D293523BB5E0A001A877D /* LinkPreviewChatTableViewCell.swift */, FECEF29C23B7C36F007EC323 /* AttachmentChatTableViewCell.swift */, FE2332E0242CCDB400008ED4 /* InvitationChatTableViewCell.swift */, FEDE00C8266CEF270019CC1C /* ChatTableViewMarkerCell.swift */, FEA6DCBB271A01770079DBEC /* LocationChatTableViewCell.swift */, ); path = conversation; sourceTree = ""; }; FE11D3B1266241A200CC883F /* combine */ = { isa = PBXGroup; children = ( FE11D3B3266241BD00CC883F /* Publisher+OnlyGetter.swift */, FE11D3B2266241BD00CC883F /* Publisher+ThrottledSink.swift */, FE2A00BA27F89BF500AF0152 /* Publisher+ThrottleFixed.swift */, ); path = combine; sourceTree = ""; }; FE11D3C32662AB6300CC883F /* crypto */ = { isa = PBXGroup; children = ( FE11D3C62662AB8900CC883F /* SSLCertificate.swift */, FE11D3C52662AB8900CC883F /* SSLContext.swift */, FE11D3C42662AB8900CC883F /* SSLProcessor.swift */, FE759FDA2370B384001E78D9 /* Cipher+AES.swift */, ); path = crypto; sourceTree = ""; }; FE1908902584D69400CA049F /* Frameworks */ = { isa = PBXGroup; children = ( FE1A349F258CD3E10058B86A /* WebRTC.xcframework */, FE1908962584D69400CA049F /* OpenSSL.xcframework */, ); path = Frameworks; sourceTree = ""; }; FE4071E221E2603400F09B58 /* voip */ = { isa = PBXGroup; children = ( FE4071E321E2605900F09B58 /* VideoCallController.swift */, FEA7BF5A21E50C5800D9E36C /* JingleManager.swift */, FEA7BF5C21E50CAB00D9E36C /* JingleManager_Session.swift */, FE233CD421E6846E00099281 /* CameraPreviewView.swift */, FE2D481924505F1600C13CE5 /* CallManager.swift */, FE2D481B24518C2800C13CE5 /* RTCCameraVideoCapturer_Format.swift */, FE1A07472525EDD4004F38A0 /* ExternalServiceDiscovery_Service_extension.swift */, FE0AE13426B575820010D2E2 /* MeetController.swift */, FE0AE13626B58C4F0010D2E2 /* MeetManager.swift */, FE8885ED26BC6B570095E25E /* CreateMeetingViewController.swift */, FE9D4DA426C1375300DD295A /* InviteToMeetingViewController.swift */, ); path = voip; sourceTree = ""; }; FE4DDF541F39E0B500A4CE5A /* SiskinIM - Share */ = { isa = PBXGroup; children = ( FE4DDF621F3A12AD00A4CE5A /* SiskinIM - Share.entitlements */, FE4DDF551F39E0B500A4CE5A /* ShareViewController.swift */, FE4DDF571F39E0B500A4CE5A /* MainInterface.storyboard */, FE4DDF5A1F39E0B500A4CE5A /* Info.plist */, FE258EA91F3B8BC90042CED9 /* Assets.xcassets */, ); path = "SiskinIM - Share"; sourceTree = ""; }; FE5079FD1CDB7B3B001A015C /* chat */ = { isa = PBXGroup; children = ( FE507A011CDB7B3B001A015C /* ChatViewController.swift */, FEC514251CEB74F8003AF765 /* BaseChatViewController.swift */, FE0E30DE2535B9D20030F8C5 /* BaseChatViewController+Share.swift */, FE0E30F02535BB530030F8C5 /* BaseChatViewController+ShareFile.swift */, FE0E30E82535BA910030F8C5 /* BaseChatViewController+ShareMedia.swift */, FEB5EC9C1F6AE448007FE0E7 /* BaseChatViewControllerWithDataSourceContextMenuAndToolbar.swift */, FEF19F152348E781005CFE9A /* BaseChatViewControllerWithDataSource.swift */, FE43E43723BF3DE80079BD9B /* ChatAttachementsController.swift */, FE9EA16A23BF9DB2008C401A /* ChatAttachementsCellView.swift */, FE3A427426DA70B700D914CE /* ChatViewInputBar.swift */, FEA6DCB7271890090079DBEC /* ShareLocationController.swift */, FEA6DCB92719DF230079DBEC /* ShareLocationSearchResultsController.swift */, ); path = chat; sourceTree = ""; }; FE507A021CDB7B3B001A015C /* database */ = { isa = PBXGroup; children = ( FE11D2EF2662392B00CC883F /* model */, FEA518A32661593B00523EF2 /* Database.swift */, FEA518B82661618D00523EF2 /* DatabaseMigrator.swift */, FE507A061CDB7B3B001A015C /* DBRosterStore.swift */, FE507A031CDB7B3B001A015C /* DBChatHistoryStore.swift */, FE507A041CDB7B3B001A015C /* DBChatStore.swift */, FE7F645A1D281B1C00B9DF56 /* DBCapabilitiesCache.swift */, FED353882270C1D000B69C53 /* DBOMEMOStore.swift */, FEC79194241ABEF4007BE572 /* MessageState.swift */, FEBC12F324C70E2900689475 /* DBChatHistorySyncStore.swift */, FE11D32226623C2F00CC883F /* DBChatMarkersStore.swift */, FE11D32726623C7700CC883F /* DBChatStore+ChannelStore.swift */, FE11D32C26623CA300CC883F /* DBChatStore+ChatStore.swift */, FE11D33126623CD000CC883F /* DBChatStore+RoomStore.swift */, FE11D33626623D1E00CC883F /* DBVCardStore.swift */, ); path = database; sourceTree = ""; }; FE507A071CDB7B3B001A015C /* roster */ = { isa = PBXGroup; children = ( FE507A081CDB7B3B001A015C /* RosterItemTableViewCell.swift */, FE507A091CDB7B3B001A015C /* RosterViewController.swift */, FE3A45CE1CE49D3300C36264 /* RosterItemEditViewController.swift */, FE96259F1D9AE7CB00D07118 /* RosterProvider.swift */, FEDCBF661D9C3EE700AE9129 /* RosterProviderFlat.swift */, FEDCBF681D9C53BA00AE9129 /* RosterProviderGrouped.swift */, FE8DD9C6221B15DC0090F5AA /* AbstractRosterViewController.swift */, ); path = roster; sourceTree = ""; }; FE507A0A1CDB7B3B001A015C /* settings */ = { isa = PBXGroup; children = ( FE507A0B1CDB7B3B001A015C /* AccountTableViewCell.swift */, FE507A0C1CDB7B3B001A015C /* AddAccountController.swift */, FE507A0D1CDB7B3B001A015C /* SettingsViewController.swift */, FEF80DB11CDBBBFE005645A7 /* AccountSettingsViewController.swift */, FE80BDAA1D953FC4001914B0 /* SetupViewController.swift */, FE65455F1E9E7B85006A14AC /* RegisterAccountController.swift */, FE6545611E9E7FDE006A14AC /* AccountDomainTableViewCell.swift */, FE6545631E9E8B67006A14AC /* ServerSelectorTableViewCell.swift */, FE9E136C1F25F5F7005C0EE5 /* ChatSettingsViewController.swift */, FE9E136E1F26049A005C0EE5 /* NotificationSettingsViewController.swift */, FE9E13701F2606E9005C0EE5 /* ContactsSettingsViewController.swift */, FE00157E2019090300490340 /* ExperimentalSettingsViewController.swift */, FE2809802167CE18002F5BD0 /* server_features_list.xml */, FE2809822167CF1B002F5BD0 /* ServerFeaturesViewController.swift */, FE719E7D2274D20D007CEEC9 /* OMEMOFingerprintsController.swift */, FEDC678F238B05E4005C0FAB /* BlockedContactsController.swift */, FE17808C23EB4C7F00A1EA76 /* AccountQRCodeController.swift */, FE0E31112537288A0030F8C5 /* MediaSettingsVIewController.swift */, FE82A72A268CC0740049C844 /* DeviceMemoryUsageTableViewCell.swift */, FE0AE0B326A9DD6D0010D2E2 /* SetAccountSettingsController.swift */, FED4043F283E9C5800C91BCF /* AccountConnectivitySettingsViewController.swift */, ); path = settings; sourceTree = ""; }; FE507A0E1CDB7B3B001A015C /* ui */ = { isa = PBXGroup; children = ( FE8885EA26BAEA350095E25E /* suggestions */, FE507A0F1CDB7B3B001A015C /* AvatarStatusView.swift */, FE507A101CDB7B3B001A015C /* CustomTabBarController.swift */, FE507A111CDB7B3B001A015C /* GlobalSplitViewController.swift */, FEDE93861D07564F00CA60A9 /* SwitchTableViewCell.swift */, FE1DCCA11EA52CE200850563 /* DataFormController.swift */, FEE097611F1FCE1800B1CEAB /* TablePicketViewController.swift */, FE9E13721F260B33005C0EE5 /* StepperTableViewCell.swift */, FEA308CF1F27A063002EF4C0 /* NavigationControllerWrappingSegue.swift */, FEFB63AC1F31E4EE00EFB3E7 /* MainTabBarController.swift */, FE3DCCED1FE18334008B6C8B /* CertificateErrorAlert.swift */, FE4071E721E2653700F09B58 /* RoundButton.swift */, FE233CDC21EA062E00099281 /* AboutController.swift */, FE137A4B21F75660006B7F7C /* ChatBottomView.swift */, FE36B3C721FA52E000D1F037 /* EmptyViewController.swift */, FE31291B222C0D1500A92863 /* AvatarView.swift */, FE65D62722E9F8EB0065DEA5 /* Markdown.swift */, FEF8255424BA0AFE00820108 /* MessageTextView.swift */, FE4A94A926679D65000A96E5 /* EnumTableViewCell.swift */, FE82A728268C83020049C844 /* ChartView.swift */, FE0AE0B526AED7870010D2E2 /* GetInTouchViewController.swift */, ); path = ui; sourceTree = ""; }; FE507A121CDB7B3B001A015C /* util */ = { isa = PBXGroup; children = ( FEB62C4F1DA80956001500D5 /* AvatarStore.swift */, FE11D3B92662849E00CC883F /* ServerCertificateInfo.swift */, FE9D4DA626C7E87C00DD295A /* AudioSession.swift */, FE11D3B1266241A200CC883F /* combine */, FE719E752271B2BA007CEEC9 /* OpenSSL_AES_GCM_Engine.swift */, FE507A131CDB7B3B001A015C /* AccountManager.swift */, FE507A141CDB7B3B001A015C /* AvatarManager.swift */, FEDE93881D081C3D00CA60A9 /* Settings.swift */, FE7F9302200FD5AC004C6195 /* AccountManagerScramSaltedPasswordCache.swift */, FE137A4721F6464D006B7F7C /* UIColor_mix.swift */, FE719E772271B439007CEEC9 /* MessageEncryption.swift */, FEF19F092347619D005CFE9A /* TasksQueue.swift */, FE75A004237475CD001E78D9 /* MainNotificationManagerProvider.swift */, FE75A0112376E73C001E78D9 /* SiskinPushNotificationsModuleProvider.swift */, FEDC678D238B03C1005C0FAB /* AppStoryboard.swift */, FECEF29323B7933A007EC323 /* MetadataCache.swift */, FECEF29523B7B076007EC323 /* DownloadStore.swift */, FE7D293323B919FF001A877D /* DownloadManager.swift */, FE3E38852428C21100D3A8E8 /* OSLog.swift */, FE0E30FD253714050030F8C5 /* MediaHelper.swift */, FE11D39C26623F2300CC883F /* Array+IndexChanges.swift */, FE11D3D02662B91E00CC883F /* PresenceStore.swift */, FE11D3D926639D1600CC883F /* ContactManager.swift */, FE11D3E526639DEA00CC883F /* VCardManager.swift */, FE1FA7152663AFFD0010333E /* CurrentDatePublisher.swift */, FEDE00AF266CC7850019CC1C /* InvitationsManager.swift */, ); path = util; sourceTree = ""; }; FE60F29A1ED48B470030D411 /* Frameworks */ = { isa = PBXGroup; children = ( FE759FE72371C966001E78D9 /* libxml2.tbd */, FE759FDD2371988B001E78D9 /* libsqlite3.tbd */, FED353732270BBA500B69C53 /* TigaseSwiftOMEMO.framework */, FE60F29B1ED48B470030D411 /* libxml2.tbd */, ); name = Frameworks; sourceTree = ""; }; FE759FA22370ACA4001E78D9 /* NotificationService */ = { isa = PBXGroup; children = ( FE759FE12371C79E001E78D9 /* NotificationService.entitlements */, FE759FA32370ACA4001E78D9 /* NotificationService.swift */, FE759FA52370ACA4001E78D9 /* Info.plist */, ); path = NotificationService; sourceTree = ""; }; FE759FC62370B2A4001E78D9 /* Shared */ = { isa = PBXGroup; children = ( FEF0967E26D643A9001C0454 /* ui */, FE759FFF237435A0001E78D9 /* notifications */, FE759FDC23719865001E78D9 /* database */, FE759FD62370B316001E78D9 /* util */, FE759FC72370B2A4001E78D9 /* Shared.h */, FE759FC82370B2A4001E78D9 /* Info.plist */, FE759FF823742AC1001E78D9 /* NotificationCategory.swift */, ); path = Shared; sourceTree = ""; }; FE759FD62370B316001E78D9 /* util */ = { isa = PBXGroup; children = ( FE11D3C32662AB6300CC883F /* crypto */, FEF0967826D642EA001C0454 /* MediaHelper.swift */, FE0E3102253714420030F8C5 /* ImageQuality.swift */, FE0E3107253714570030F8C5 /* VideoQuality.swift */, FE0E30E32535BA520030F8C5 /* HTTPFileUploadHelper.swift */, ); path = util; sourceTree = ""; }; FE759FDC23719865001E78D9 /* database */ = { isa = PBXGroup; children = ( FE11D2E926616F0100CC883F /* Database.swift */, FEDE0097266BCD7A0019CC1C /* ConversationType.swift */, ); path = database; sourceTree = ""; }; FE759FFF237435A0001E78D9 /* notifications */ = { isa = PBXGroup; children = ( FE75A007237585DC001E78D9 /* NotificationEncryptionKeys.swift */, FEDE0092266BCA450019CC1C /* ConversationNotifications.swift */, FEDE009C266BE3BD0019CC1C /* NotificationsManagerHelper.swift */, ); path = notifications; sourceTree = ""; }; FE7607DA2812BC2B009C2A93 /* bookmarks */ = { isa = PBXGroup; children = ( FE64445D281314ED002D6E8E /* BookmarksController.swift */, FE64445F28131538002D6E8E /* BookmarkViewCell.swift */, FE644461281334ED002D6E8E /* BookmarkItem.swift */, ); path = bookmarks; sourceTree = ""; }; FE8885EA26BAEA350095E25E /* suggestions */ = { isa = PBXGroup; children = ( FE8885EB26BAEAAE0095E25E /* MultiContactSelectionView.swift */, ); path = suggestions; sourceTree = ""; }; FE94E5181CCBA74F00FAE755 = { isa = PBXGroup; children = ( FE0691FC27F4D69F004E341E /* .bartycrouch.toml */, FE1908902584D69400CA049F /* Frameworks */, FE759FA22370ACA4001E78D9 /* NotificationService */, FE759FC62370B2A4001E78D9 /* Shared */, FE60F29A1ED48B470030D411 /* Frameworks */, FE94E55D1CCCC14E00FAE755 /* libsqlite3.tbd */, FE94E5221CCBA74F00FAE755 /* Products */, FE5079EF1CD3CA91001A015C /* Security.framework */, FE94E5231CCBA74F00FAE755 /* SiskinIM */, FE4DDF541F39E0B500A4CE5A /* SiskinIM - Share */, FE80BDA91D92974C001914B0 /* SiskinIM.entitlements */, ); sourceTree = ""; }; FE94E5221CCBA74F00FAE755 /* Products */ = { isa = PBXGroup; children = ( FE94E5211CCBA74F00FAE755 /* Siskin.app */, FE4DDF531F39E0B500A4CE5A /* Siskin IM - Share.appex */, FE759FA12370ACA4001E78D9 /* NotificationService.appex */, FE759FC52370B2A4001E78D9 /* Shared.framework */, ); name = Products; sourceTree = ""; }; FE94E5231CCBA74F00FAE755 /* SiskinIM */ = { isa = PBXGroup; children = ( FE7607DA2812BC2B009C2A93 /* bookmarks */, FE9D4DA226BE9EFD00DD295A /* foreman.mp4 */, FE168ACC1CCD197A003F8B26 /* db-schema-1.sql */, FE2A0E8D1F74012D006ADF08 /* db-schema-2.sql */, FE4496C31F87911C009F649C /* db-schema-3.sql */, FE8DD9CA221DBED80090F5AA /* db-schema-4.sql */, FE719E732271AF88007CEEC9 /* db-schema-5.sql */, FEE9608B22F2F8950009B191 /* db-schema-6.sql */, FEF19F0523474943005CFE9A /* db-schema-7.sql */, FE759FF423741527001E78D9 /* db-schema-8.sql */, FECEF29723B7B838007EC323 /* db-schema-9.sql */, FE10BCF223FD4EF000E214F3 /* db-schema-10.sql */, FEC79198241BE89E007BE572 /* db-schema-11.sql */, FE3BA0BF24B61583000C80D4 /* db-schema-12.sql */, FEBC12F124C70DE000689475 /* db-schema-13.sql */, FEDE00C3266CE1680019CC1C /* db-schema-14.sql */, FEDE00A1266BE4300019CC1C /* notifications */, FE11D38F26623E8200CC883F /* conversation */, FEE49DD3242687D800900BBB /* chats */, FEE49DD22426879500900BBB /* channel */, FEE49DD12426879400900BBB /* groupchat */, FE4071E221E2603400F09B58 /* voip */, FE86C4481F7BFF93009E3CB8 /* SiskinIM-Bridging-Header.h */, FE94E5241CCBA74F00FAE755 /* AppDelegate.swift */, FE94E52D1CCBA74F00FAE755 /* Assets.xcassets */, FE94E5321CCBA74F00FAE755 /* Info.plist */, FE94E52F1CCBA74F00FAE755 /* LaunchScreen.storyboard */, FE94E52A1CCBA74F00FAE755 /* Main.storyboard */, FEBA82FB26F76A8000347D89 /* VoIP.storyboard */, FEBA82FE26F76A8700347D89 /* Groupchat.storyboard */, FE5079FD1CDB7B3B001A015C /* chat */, FEDE93951D0C200C00CA60A9 /* contacts */, FE507A021CDB7B3B001A015C /* database */, FE507A071CDB7B3B001A015C /* roster */, FEA8D6601F30F54B0077C12F /* service */, FE507A0A1CDB7B3B001A015C /* settings */, FE507A0E1CDB7B3B001A015C /* ui */, FE507A121CDB7B3B001A015C /* util */, FEDE938A1D08A4DD00CA60A9 /* vcard */, FE01ADA11E214CEA00FA7E65 /* xmpp */, FEBA830126F76A8D00347D89 /* Info.storyboard */, FEBA830426F76A9300347D89 /* Settings.storyboard */, FEBA830726F76A9800347D89 /* Account.storyboard */, FEBA830A26F76A9C00347D89 /* Conversation.storyboard */, FEBA830D26F76AA200347D89 /* MIX.storyboard */, FEBA835626F8DF1300347D89 /* Localizable.strings */, ); path = SiskinIM; sourceTree = ""; }; FEA8D6601F30F54B0077C12F /* service */ = { isa = PBXGroup; children = ( FEA8D6611F30F54B0077C12F /* XmppService.swift */, FE31DDE3201261A200C2AB1D /* DNSSrvDiskCache.swift */, FE00157C2017617B00490340 /* StreamFeaturesCache.swift */, FE1AC8F6216B8AB700D4CDAB /* NewFeaturesDetector.swift */, FEF19F0123473B9E005CFE9A /* XmppServiceEventHandler.swift */, FEF19F0323473C06005CFE9A /* MessageEventHandler.swift */, FEF19F0B23476466005CFE9A /* MucEventHandler.swift */, FEF19F0F2348A046005CFE9A /* PresenceRosterEventHandler.swift */, FEF19F112348A3B8005CFE9A /* AvatarEventHandler.swift */, FE75A00E2375F324001E78D9 /* PushEventHandler.swift */, FEDC6789238A9F16005C0FAB /* BlockedEventHandler.swift */, FE3E3879242765E700D3A8E8 /* MixEventHandler.swift */, FE11D3BE2662852A00CC883F /* XMPPClient_extension.swift */, FE0AE13826B7EA770010D2E2 /* MeetEventHandler.swift */, ); path = service; sourceTree = ""; }; FEDE00A1266BE4300019CC1C /* notifications */ = { isa = PBXGroup; children = ( FE75A00223743A5C001E78D9 /* NotificationManager.swift */, FE759FFA23742C48001E78D9 /* NotificationCenterDelegate.swift */, ); path = notifications; sourceTree = ""; }; FEDE938A1D08A4DD00CA60A9 /* vcard */ = { isa = PBXGroup; children = ( FEDE938B1D08AFE800CA60A9 /* VCardEditViewController.swift */, FEDE938F1D09BB8300CA60A9 /* VCardEditPhoneTableViewCell.swift */, FEDE93911D09E74100CA60A9 /* VCardEditAddressTableViewCell.swift */, FEDE93931D0AC01200CA60A9 /* VCardEditEmailTableViewCell.swift */, FEA8D65C1F2F6AF60077C12F /* VCardEntryTypeAwareTableViewCell.swift */, FEA303AA24694447004A3B3E /* VCardAvatarEditCell.swift */, FEA303AC24696604004A3B3E /* VCardTextEditCell.swift */, ); path = vcard; sourceTree = ""; }; FEDE93951D0C200C00CA60A9 /* contacts */ = { isa = PBXGroup; children = ( FEDE93961D0C202600CA60A9 /* ContactViewController.swift */, FEDE93981D0C207100CA60A9 /* ContactBasicTableViewCell.swift */, FEDE939A1D0C38B000CA60A9 /* ContactFormTableViewCell.swift */, FE719E7B22730DC3007CEEC9 /* OMEMOIdentityTableViewCell.swift */, ); path = contacts; sourceTree = ""; }; FEE49DD12426879400900BBB /* groupchat */ = { isa = PBXGroup; children = ( FEC514271CEB82E9003AF765 /* MucChatViewController.swift */, FEAC71781CECE50400ABABEF /* MucChatOccupantsTableViewController.swift */, FEAC717A1CECE70100ABABEF /* MucChatOccupantsTableViewCell.swift */, FE8DD9C4221B153A0090F5AA /* InviteViewController.swift */, FEE9608922F191980009B191 /* MucChatSettingsViewController.swift */, ); path = groupchat; sourceTree = ""; }; FEE49DD22426879500900BBB /* channel */ = { isa = PBXGroup; children = ( FEE49DD6242688E100900BBB /* ChannelViewController.swift */, FE3E387D2427A09A00D3A8E8 /* ChannelSelectToJoinViewController.swift */, FE3E387F2427B8A900D3A8E8 /* ChannelsHelper.swift */, FE3E38812427BF8600D3A8E8 /* ChannelSelectAccountAndComponentController.swift */, FE3E38832427E34300D3A8E8 /* ChannelJoinViewController.swift */, FE3E38872428D9DB00D3A8E8 /* ChannelCreateViewController.swift */, FE3E388B2429FAC500D3A8E8 /* ChannelSettingsViewController.swift */, FE3E388D242A251E00D3A8E8 /* ChannelEditInfoController.swift */, FE3E388F242A98C000D3A8E8 /* ChannelParticipantsController.swift */, FE2332DA242B9C2300008ED4 /* ChannelInviteController.swift */, FE2332E2242CE8D600008ED4 /* ChannelBlockedUsersController.swift */, FE1EE9D527903F58000FA599 /* ChannelSelectNewOwnerViewController.swift */, ); path = channel; sourceTree = ""; }; FEE49DD3242687D800900BBB /* chats */ = { isa = PBXGroup; children = ( FE5079FE1CDB7B3B001A015C /* ChatsListTableViewCell.swift */, FE5079FF1CDB7B3B001A015C /* ChatsListViewController.swift */, ); path = chats; sourceTree = ""; }; FEF0967E26D643A9001C0454 /* ui */ = { isa = PBXGroup; children = ( FE3E38892429364D00D3A8E8 /* UIImage.swift */, ); path = ui; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ FE759FC02370B2A4001E78D9 /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( FE759FC92370B2A4001E78D9 /* Shared.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ FE4DDF521F39E0B500A4CE5A /* Siskin IM - Share */ = { isa = PBXNativeTarget; buildConfigurationList = FE4DDF5E1F39E0B600A4CE5A /* Build configuration list for PBXNativeTarget "Siskin IM - Share" */; buildPhases = ( FE4DDF4F1F39E0B500A4CE5A /* Sources */, FE4DDF501F39E0B500A4CE5A /* Frameworks */, FE4DDF511F39E0B500A4CE5A /* Resources */, ); buildRules = ( ); dependencies = ( FE759FE52371C83D001E78D9 /* PBXTargetDependency */, ); name = "Siskin IM - Share"; productName = "SiskinIM - Share"; productReference = FE4DDF531F39E0B500A4CE5A /* Siskin IM - Share.appex */; productType = "com.apple.product-type.app-extension"; }; FE759FA02370ACA4001E78D9 /* NotificationService */ = { isa = PBXNativeTarget; buildConfigurationList = FE759FAB2370ACA4001E78D9 /* Build configuration list for PBXNativeTarget "NotificationService" */; buildPhases = ( FE759F9D2370ACA4001E78D9 /* Sources */, FE759F9E2370ACA4001E78D9 /* Frameworks */, FE759F9F2370ACA4001E78D9 /* Resources */, ); buildRules = ( ); dependencies = ( FE759FD42370B2F2001E78D9 /* PBXTargetDependency */, ); name = NotificationService; productName = NotificationService; productReference = FE759FA12370ACA4001E78D9 /* NotificationService.appex */; productType = "com.apple.product-type.app-extension"; }; FE759FC42370B2A4001E78D9 /* Shared */ = { isa = PBXNativeTarget; buildConfigurationList = FE759FCE2370B2A4001E78D9 /* Build configuration list for PBXNativeTarget "Shared" */; buildPhases = ( FE759FC02370B2A4001E78D9 /* Headers */, FE759FC12370B2A4001E78D9 /* Sources */, FE759FC22370B2A4001E78D9 /* Frameworks */, FE759FC32370B2A4001E78D9 /* Resources */, ); buildRules = ( ); dependencies = ( ); name = Shared; packageProductDependencies = ( FEA518B0266159B900523EF2 /* TigaseSQLite3 */, FEA518C1266165A100523EF2 /* TigaseLogging */, FE392E02289AF47D006AA914 /* MartinOMEMO */, ); productName = Shared; productReference = FE759FC52370B2A4001E78D9 /* Shared.framework */; productType = "com.apple.product-type.framework"; }; FE94E5201CCBA74F00FAE755 /* Siskin IM */ = { isa = PBXNativeTarget; buildConfigurationList = FE94E54B1CCBA74F00FAE755 /* Build configuration list for PBXNativeTarget "Siskin IM" */; buildPhases = ( FEBA835126F8D81900347D89 /* ShellScript */, FE94E51D1CCBA74F00FAE755 /* Sources */, FE94E51E1CCBA74F00FAE755 /* Frameworks */, FE94E51F1CCBA74F00FAE755 /* Resources */, FECD0F2A1CDB8B1B00420DF5 /* Mark TODO and FIX ME */, FEF80DB71CDCC508005645A7 /* Embed Frameworks */, FE0473391D86CEE700E6D6CE /* Trim Framework Executables */, FE4DDF611F39E0B600A4CE5A /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( FE4DDF5C1F39E0B500A4CE5A /* PBXTargetDependency */, FE759FA72370ACA4001E78D9 /* PBXTargetDependency */, FE759FCB2370B2A4001E78D9 /* PBXTargetDependency */, ); name = "Siskin IM"; packageProductDependencies = ( ); productName = SiskinIM; productReference = FE94E5211CCBA74F00FAE755 /* Siskin.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ FE94E5191CCBA74F00FAE755 /* Project object */ = { isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1120; LastUpgradeCheck = 1140; ORGANIZATIONNAME = "Tigase, Inc."; TargetAttributes = { FE4DDF521F39E0B500A4CE5A = { CreatedOnToolsVersion = 8.3.3; DevelopmentTeam = YBEYW6E35C; LastSwiftMigration = 1020; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 1; }; com.apple.Keychain = { enabled = 1; }; }; }; FE759FA02370ACA4001E78D9 = { CreatedOnToolsVersion = 11.2; DevelopmentTeam = YBEYW6E35C; ProvisioningStyle = Automatic; }; FE759FC42370B2A4001E78D9 = { CreatedOnToolsVersion = 11.2; DevelopmentTeam = YBEYW6E35C; LastSwiftMigration = 1120; ProvisioningStyle = Automatic; }; FE94E5201CCBA74F00FAE755 = { CreatedOnToolsVersion = 7.3; DevelopmentTeam = YBEYW6E35C; LastSwiftMigration = 1020; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 1; }; com.apple.BackgroundModes = { enabled = 1; }; com.apple.Keychain = { enabled = 1; }; com.apple.Push = { enabled = 1; }; }; }; }; }; buildConfigurationList = FE94E51C1CCBA74F00FAE755 /* Build configuration list for PBXProject "SiskinIM" */; compatibilityVersion = "Xcode 3.2"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, pl, de, es, ); mainGroup = FE94E5181CCBA74F00FAE755; packageReferences = ( FEA5189D2661573F00523EF2 /* XCRemoteSwiftPackageReference "tigase-sqlite3.swift" */, FEA518C0266165A100523EF2 /* XCRemoteSwiftPackageReference "tigase-logging.swift" */, FE392E01289AF3B4006AA914 /* XCRemoteSwiftPackageReference "MartinOMEMO" */, ); productRefGroup = FE94E5221CCBA74F00FAE755 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( FE94E5201CCBA74F00FAE755 /* Siskin IM */, FE4DDF521F39E0B500A4CE5A /* Siskin IM - Share */, FE759FA02370ACA4001E78D9 /* NotificationService */, FE759FC42370B2A4001E78D9 /* Shared */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ FE4DDF511F39E0B500A4CE5A /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( FE258EAA1F3B8BC90042CED9 /* Assets.xcassets in Resources */, FE4DDF591F39E0B500A4CE5A /* MainInterface.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; FE759F9F2370ACA4001E78D9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; FE759FC32370B2A4001E78D9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; FE94E51F1CCBA74F00FAE755 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( FEE2D19126866503007AEE74 /* db-schema-13.sql in Resources */, FEE2D18C268664FF007AEE74 /* db-schema-2.sql in Resources */, FEE2D18B268664FF007AEE74 /* db-schema-3.sql in Resources */, FEE2D19226866503007AEE74 /* db-schema-11.sql in Resources */, FEBA82FF26F76A8D00347D89 /* Info.storyboard in Resources */, FEE2D18E268664FF007AEE74 /* db-schema-7.sql in Resources */, FEE2D189268664FF007AEE74 /* db-schema-1.sql in Resources */, FE94E5311CCBA74F00FAE755 /* LaunchScreen.storyboard in Resources */, FEE2D18D268664FF007AEE74 /* db-schema-6.sql in Resources */, FEBA835426F8DF1300347D89 /* Localizable.strings in Resources */, FEBA82FC26F76A8700347D89 /* Groupchat.storyboard in Resources */, FEE2D19526866503007AEE74 /* db-schema-12.sql in Resources */, FEBA830226F76A9300347D89 /* Settings.storyboard in Resources */, FE94E52E1CCBA74F00FAE755 /* Assets.xcassets in Resources */, FEE2D19426866503007AEE74 /* db-schema-14.sql in Resources */, FEE2D18A268664FF007AEE74 /* db-schema-4.sql in Resources */, FEBA82F926F76A8000347D89 /* VoIP.storyboard in Resources */, FEE2D188268664FF007AEE74 /* db-schema-8.sql in Resources */, FEE2D18F268664FF007AEE74 /* db-schema-5.sql in Resources */, FE94E52C1CCBA74F00FAE755 /* Main.storyboard in Resources */, FEBA830826F76A9C00347D89 /* Conversation.storyboard in Resources */, FEBA830526F76A9800347D89 /* Account.storyboard in Resources */, FEBA830B26F76AA200347D89 /* MIX.storyboard in Resources */, FE2809812167CE18002F5BD0 /* server_features_list.xml in Resources */, FEE2D19326866503007AEE74 /* db-schema-10.sql in Resources */, FEE2D19026866503007AEE74 /* db-schema-9.sql in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ FE0473391D86CEE700E6D6CE /* Trim Framework Executables */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); name = "Trim Framework Executables"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "#\"${SRCROOT}/\"trim.sh TigaseSwift\n#\"${SRCROOT}/\"trim.sh WebRTC\n"; }; FEBA835126F8D81900347D89 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( ); outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "if which bartycrouch > /dev/null; then\n bartycrouch update -x\n bartycrouch lint -x\nelse\n echo \"warning: BartyCrouch not installed, download it from https://github.com/Flinesoft/BartyCrouch\"\nfi\n"; }; FECD0F2A1CDB8B1B00420DF5 /* Mark TODO and FIX ME */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); name = "Mark TODO and FIX ME"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${SRCROOT}/\"swiftScript.swift argumentOne argumentTwo\n"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ FE4DDF4F1F39E0B500A4CE5A /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( FE4DDF561F39E0B500A4CE5A /* ShareViewController.swift in Sources */, FEF0967126D50ADC001C0454 /* UIColor_mix.swift in Sources */, FEF0968326D7AA02001C0454 /* ServerCertificateInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; FE759F9D2370ACA4001E78D9 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( FE759FA42370ACA4001E78D9 /* NotificationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; FE759FC12370B2A4001E78D9 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( FE75A008237585DC001E78D9 /* NotificationEncryptionKeys.swift in Sources */, FE11D3C72662AB8900CC883F /* SSLProcessor.swift in Sources */, FEF0967C26D6437D001C0454 /* HTTPFileUploadHelper.swift in Sources */, FE11D3C82662AB8900CC883F /* SSLContext.swift in Sources */, FEDE0098266BCD7A0019CC1C /* ConversationType.swift in Sources */, FE11D3C92662AB8900CC883F /* SSLCertificate.swift in Sources */, FEF0967D26D6439E001C0454 /* UIImage.swift in Sources */, FEF0967A26D64347001C0454 /* ImageQuality.swift in Sources */, FEDE009D266BE3BD0019CC1C /* NotificationsManagerHelper.swift in Sources */, FEF0967926D642EA001C0454 /* MediaHelper.swift in Sources */, FE759FDB2370B384001E78D9 /* Cipher+AES.swift in Sources */, FE11D2EA26616F0100CC883F /* Database.swift in Sources */, FEF0967B26D64347001C0454 /* VideoQuality.swift in Sources */, FE759FF923742AC1001E78D9 /* NotificationCategory.swift in Sources */, FEDE0093266BCA450019CC1C /* ConversationNotifications.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; FE94E51D1CCBA74F00FAE755 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( FE64445E281314ED002D6E8E /* BookmarksController.swift in Sources */, FEDE93921D09E74100CA60A9 /* VCardEditAddressTableViewCell.swift in Sources */, FEAC71791CECE50400ABABEF /* MucChatOccupantsTableViewController.swift in Sources */, FE3E387E2427A09A00D3A8E8 /* ChannelSelectToJoinViewController.swift in Sources */, FEF19F162348E781005CFE9A /* BaseChatViewControllerWithDataSource.swift in Sources */, FEF19F0223473B9E005CFE9A /* XmppServiceEventHandler.swift in Sources */, FEDE93971D0C202600CA60A9 /* ContactViewController.swift in Sources */, FEE49DCE2424C1F800900BBB /* ConversationLogController.swift in Sources */, FE2A00BB27F89BF500AF0152 /* Publisher+ThrottleFixed.swift in Sources */, FE1AC8F7216B8AB700D4CDAB /* NewFeaturesDetector.swift in Sources */, FE11D35726623D6800CC883F /* ConversationEntrySender.swift in Sources */, FE17808D23EB4C7F00A1EA76 /* AccountQRCodeController.swift in Sources */, FE11D36726623D7000CC883F /* DisplayableIdProtocol.swift in Sources */, FEF0968226D7A9FB001C0454 /* ServerCertificateInfo.swift in Sources */, FE64446028131538002D6E8E /* BookmarkViewCell.swift in Sources */, FE11D35F26623D6D00CC883F /* Conversation.swift in Sources */, FE719E762271B2BA007CEEC9 /* OpenSSL_AES_GCM_Engine.swift in Sources */, FE1EE9D627903F58000FA599 /* ChannelSelectNewOwnerViewController.swift in Sources */, FE11D2E426616B7300CC883F /* Database.swift in Sources */, FEDC678A238A9F16005C0FAB /* BlockedEventHandler.swift in Sources */, FE4071E821E2653700F09B58 /* RoundButton.swift in Sources */, FEAC717B1CECE70100ABABEF /* MucChatOccupantsTableViewCell.swift in Sources */, FE11D3B5266241BD00CC883F /* Publisher+OnlyGetter.swift in Sources */, FE7F9303200FD5AC004C6195 /* AccountManagerScramSaltedPasswordCache.swift in Sources */, FE11D3BF2662852A00CC883F /* XMPPClient_extension.swift in Sources */, FE82A729268C83020049C844 /* ChartView.swift in Sources */, FE9625A01D9AE7CB00D07118 /* RosterProvider.swift in Sources */, FEF19F0423473C06005CFE9A /* MessageEventHandler.swift in Sources */, FE137A4C21F75660006B7F7C /* ChatBottomView.swift in Sources */, FE3E38802427B8A900D3A8E8 /* ChannelsHelper.swift in Sources */, FE3A45CF1CE49D3300C36264 /* RosterItemEditViewController.swift in Sources */, FE0AE13726B58C4F0010D2E2 /* MeetManager.swift in Sources */, FEE49DD7242688E100900BBB /* ChannelViewController.swift in Sources */, FE507A211CDB7B3B001A015C /* SettingsViewController.swift in Sources */, FE11D35526623D6800CC883F /* ConversationEntryRecipient.swift in Sources */, FE507A191CDB7B3B001A015C /* DBChatHistoryStore.swift in Sources */, FEA8D65D1F2F6AF60077C12F /* VCardEntryTypeAwareTableViewCell.swift in Sources */, FE8885EE26BC6B570095E25E /* CreateMeetingViewController.swift in Sources */, FEBC12F524C70E7F00689475 /* DBChatHistorySyncStore.swift in Sources */, FEDC6790238B05E4005C0FAB /* BlockedContactsController.swift in Sources */, FE2332E1242CCDB400008ED4 /* InvitationChatTableViewCell.swift in Sources */, FE3E38862428C21100D3A8E8 /* OSLog.swift in Sources */, FED40440283E9C5800C91BCF /* AccountConnectivitySettingsViewController.swift in Sources */, FECEF29423B7933A007EC323 /* MetadataCache.swift in Sources */, FE7D293423B919FF001A877D /* DownloadManager.swift in Sources */, FE137A4821F6464D006B7F7C /* UIColor_mix.swift in Sources */, FE507A1E1CDB7B3B001A015C /* RosterViewController.swift in Sources */, FEDCBF691D9C53BA00AE9129 /* RosterProviderGrouped.swift in Sources */, FEDE938C1D08AFE800CA60A9 /* VCardEditViewController.swift in Sources */, FE0AE13926B7EA770010D2E2 /* MeetEventHandler.swift in Sources */, FEDC678E238B03C1005C0FAB /* AppStoryboard.swift in Sources */, FEA303AD24696604004A3B3E /* VCardTextEditCell.swift in Sources */, FE36B3C821FA52E000D1F037 /* EmptyViewController.swift in Sources */, FE11D3A126623F4100CC883F /* Array+IndexChanges.swift in Sources */, FE507A161CDB7B3B001A015C /* ChatsListViewController.swift in Sources */, FE3E38842427E34300D3A8E8 /* ChannelJoinViewController.swift in Sources */, FE75A0122376E73C001E78D9 /* SiskinPushNotificationsModuleProvider.swift in Sources */, FE3E3890242A98C000D3A8E8 /* ChannelParticipantsController.swift in Sources */, FE01ADA91E224CF400FA7E65 /* SiskinPushNotificationsModule.swift in Sources */, FE507A1F1CDB7B3B001A015C /* AccountTableViewCell.swift in Sources */, FE11D38126623D7700CC883F /* DBChatStore+RoomStore.swift in Sources */, FEF19F122348A3B8005CFE9A /* AvatarEventHandler.swift in Sources */, FE0AE0B626AED7870010D2E2 /* GetInTouchViewController.swift in Sources */, FE0E30E92535BA910030F8C5 /* BaseChatViewController+ShareMedia.swift in Sources */, FE7F645B1D281B1C00B9DF56 /* DBCapabilitiesCache.swift in Sources */, FE11D36026623D6D00CC883F /* Chat.swift in Sources */, FE43E43823BF3DE80079BD9B /* ChatAttachementsController.swift in Sources */, FEDE93891D081C3D00CA60A9 /* Settings.swift in Sources */, FECEF29B23B7BC02007EC323 /* BaseChatTableViewCell.swift in Sources */, FEA6DCBC271A01770079DBEC /* LocationChatTableViewCell.swift in Sources */, FE3A427526DA70B700D914CE /* ChatViewInputBar.swift in Sources */, FE9D4DA526C1375300DD295A /* InviteToMeetingViewController.swift in Sources */, FECEF29E23B7C390007EC323 /* AttachmentChatTableViewCell.swift in Sources */, FED353892270C1D000B69C53 /* DBOMEMOStore.swift in Sources */, FE8DD9C5221B153A0090F5AA /* InviteViewController.swift in Sources */, FEB5EC9D1F6AE448007FE0E7 /* BaseChatViewControllerWithDataSourceContextMenuAndToolbar.swift in Sources */, FEE9608A22F191980009B191 /* MucChatSettingsViewController.swift in Sources */, FE3E38822427BF8600D3A8E8 /* ChannelSelectAccountAndComponentController.swift in Sources */, FE0E30FE253714050030F8C5 /* MediaHelper.swift in Sources */, FE11D35426623D6800CC883F /* ConversationInvitation.swift in Sources */, FE9E136F1F26049A005C0EE5 /* NotificationSettingsViewController.swift in Sources */, FE11D3E126639D3300CC883F /* ContactManager.swift in Sources */, FE31291C222C0D1500A92863 /* AvatarView.swift in Sources */, FE719E7E2274D20D007CEEC9 /* OMEMOFingerprintsController.swift in Sources */, FE759FFC23742CE5001E78D9 /* NotificationCenterDelegate.swift in Sources */, FE11D37F26623D7700CC883F /* DBVCardStore.swift in Sources */, FEF19F0C23476466005CFE9A /* MucEventHandler.swift in Sources */, FE11D33E26623D6200CC883F /* AppendixProtocol.swift in Sources */, FE1DCCA21EA52CE200850563 /* DataFormController.swift in Sources */, FE7D293623BB5E0A001A877D /* LinkPreviewChatTableViewCell.swift in Sources */, FE11D37E26623D7700CC883F /* DBChatStore+ChannelStore.swift in Sources */, FE6545641E9E8B67006A14AC /* ServerSelectorTableViewCell.swift in Sources */, FE00157F2019090300490340 /* ExperimentalSettingsViewController.swift in Sources */, FE2332E3242CE8D600008ED4 /* ChannelBlockedUsersController.swift in Sources */, FE75A006237475E2001E78D9 /* MainNotificationManagerProvider.swift in Sources */, FE1FA7162663AFFD0010333E /* CurrentDatePublisher.swift in Sources */, FE1EE9D227862A5F000FA599 /* HttpFileUploadModule.swift in Sources */, FE11D3ED26639E0100CC883F /* VCardManager.swift in Sources */, FE0E31122537288A0030F8C5 /* MediaSettingsVIewController.swift in Sources */, FEDE93871D07564F00CA60A9 /* SwitchTableViewCell.swift in Sources */, FE507A171CDB7B3B001A015C /* ChatTableViewCell.swift in Sources */, FE31DDE4201261A200C2AB1D /* DNSSrvDiskCache.swift in Sources */, FEDE00D0266CEF5E0019CC1C /* ChatTableViewMarkerCell.swift in Sources */, FE233CDD21EA062E00099281 /* AboutController.swift in Sources */, FE2D481C24518C2800C13CE5 /* RTCCameraVideoCapturer_Format.swift in Sources */, FE4A94AA26679D65000A96E5 /* EnumTableViewCell.swift in Sources */, FE11D2E526616B7300CC883F /* DatabaseMigrator.swift in Sources */, FE0AE13526B575820010D2E2 /* MeetController.swift in Sources */, FE4071E421E2605900F09B58 /* VideoCallController.swift in Sources */, FE9E13731F260B33005C0EE5 /* StepperTableViewCell.swift in Sources */, FE6545601E9E7B85006A14AC /* RegisterAccountController.swift in Sources */, FE507A181CDB7B3B001A015C /* ChatViewController.swift in Sources */, FEFB63AD1F31E4EE00EFB3E7 /* MainTabBarController.swift in Sources */, FE11D35B26623D6D00CC883F /* AccountConversations.swift in Sources */, FE65D62822E9F8EB0065DEA5 /* Markdown.swift in Sources */, FEA6DCBA2719DF230079DBEC /* ShareLocationSearchResultsController.swift in Sources */, FE11D3D52662B94500CC883F /* PresenceStore.swift in Sources */, FE507A201CDB7B3B001A015C /* AddAccountController.swift in Sources */, FE2332DB242B9C2300008ED4 /* ChannelInviteController.swift in Sources */, FE11D35326623D6800CC883F /* ConversationEntry.swift in Sources */, FE507A151CDB7B3B001A015C /* ChatsListTableViewCell.swift in Sources */, FE9EA16B23BF9DB2008C401A /* ChatAttachementsCellView.swift in Sources */, FEF19F102348A046005CFE9A /* PresenceRosterEventHandler.swift in Sources */, FE94E5251CCBA74F00FAE755 /* AppDelegate.swift in Sources */, FEF80DB21CDBBBFE005645A7 /* AccountSettingsViewController.swift in Sources */, FEDE00A8266BE4740019CC1C /* NotificationManager.swift in Sources */, FEA303AB24694447004A3B3E /* VCardAvatarEditCell.swift in Sources */, FE11D35626623D6800CC883F /* ConversationAttachment.swift in Sources */, FE6545621E9E7FDE006A14AC /* AccountDomainTableViewCell.swift in Sources */, FE507A231CDB7B3B001A015C /* CustomTabBarController.swift in Sources */, FEA8D6621F30F54B0077C12F /* XmppService.swift in Sources */, FEF19F0A2347619D005CFE9A /* TasksQueue.swift in Sources */, FE8DD9C7221B15DC0090F5AA /* AbstractRosterViewController.swift in Sources */, FE11D35026623D6800CC883F /* ConversationEntryEncryption.swift in Sources */, FEA7BF5D21E50CAB00D9E36C /* JingleManager_Session.swift in Sources */, FE11D35126623D6800CC883F /* ConversationKey.swift in Sources */, FE507A1A1CDB7B3B001A015C /* DBChatStore.swift in Sources */, FE80BDAB1D953FC4001914B0 /* SetupViewController.swift in Sources */, FEE097621F1FCE1800B1CEAB /* TablePicketViewController.swift in Sources */, FEDE93941D0AC01200CA60A9 /* VCardEditEmailTableViewCell.swift in Sources */, FE8885EC26BAEAAE0095E25E /* MultiContactSelectionView.swift in Sources */, FEF8255524BA0AFE00820108 /* MessageTextView.swift in Sources */, FE11D38026623D7700CC883F /* DBChatMarkersStore.swift in Sources */, FEDE939B1D0C38B000CA60A9 /* ContactFormTableViewCell.swift in Sources */, FEDE93991D0C207100CA60A9 /* ContactBasicTableViewCell.swift in Sources */, FE0E30F12535BB530030F8C5 /* BaseChatViewController+ShareFile.swift in Sources */, FEA6DCB8271890090079DBEC /* ShareLocationController.swift in Sources */, FE507A251CDB7B3B001A015C /* AccountManager.swift in Sources */, FE11D35C26623D6D00CC883F /* Room.swift in Sources */, FEC79195241ABEF4007BE572 /* MessageState.swift in Sources */, FE719E7C22730DC3007CEEC9 /* OMEMOIdentityTableViewCell.swift in Sources */, FEDE93901D09BB8300CA60A9 /* VCardEditPhoneTableViewCell.swift in Sources */, FE507A221CDB7B3B001A015C /* AvatarStatusView.swift in Sources */, FE719E782271B439007CEEC9 /* MessageEncryption.swift in Sources */, FE3E38882428D9DB00D3A8E8 /* ChannelCreateViewController.swift in Sources */, FE3DCCEE1FE18334008B6C8B /* CertificateErrorAlert.swift in Sources */, FE75A0102375F338001E78D9 /* PushEventHandler.swift in Sources */, FEDCBF671D9C3EE700AE9129 /* RosterProviderFlat.swift in Sources */, FE9E136D1F25F5F7005C0EE5 /* ChatSettingsViewController.swift in Sources */, FE82A72B268CC0740049C844 /* DeviceMemoryUsageTableViewCell.swift in Sources */, FE3E387A242765E800D3A8E8 /* MixEventHandler.swift in Sources */, FECEF29623B7B076007EC323 /* DownloadStore.swift in Sources */, FE0E30DF2535B9D20030F8C5 /* BaseChatViewController+Share.swift in Sources */, FE9E13711F2606E9005C0EE5 /* ContactsSettingsViewController.swift in Sources */, FE0AE0B426A9DD6D0010D2E2 /* SetAccountSettingsController.swift in Sources */, FE11D3B4266241BD00CC883F /* Publisher+ThrottledSink.swift in Sources */, FE2D481A24505F1600C13CE5 /* CallManager.swift in Sources */, FE3E388C2429FAC500D3A8E8 /* ChannelSettingsViewController.swift in Sources */, FE00157D2017617B00490340 /* StreamFeaturesCache.swift in Sources */, FE3E388E242A251E00D3A8E8 /* ChannelEditInfoController.swift in Sources */, FE9D4DA726C7E87C00DD295A /* AudioSession.swift in Sources */, FEC514261CEB74F8003AF765 /* BaseChatViewController.swift in Sources */, FE11D39826623EC500CC883F /* ConversationDataSource.swift in Sources */, FE11D35E26623D6D00CC883F /* ConversationBase.swift in Sources */, FE11D35D26623D6D00CC883F /* Channel.swift in Sources */, FEDE00B4266CC7B80019CC1C /* InvitationsManager.swift in Sources */, FEA6DCB627148D420079DBEC /* AvatarStore.swift in Sources */, FE507A1D1CDB7B3B001A015C /* RosterItemTableViewCell.swift in Sources */, FE11D38226623D7700CC883F /* DBChatStore+ChatStore.swift in Sources */, FE2809832167CF1B002F5BD0 /* ServerFeaturesViewController.swift in Sources */, FEC514281CEB82E9003AF765 /* MucChatViewController.swift in Sources */, FE644462281334ED002D6E8E /* BookmarkItem.swift in Sources */, FE1A07482525EDD4004F38A0 /* ExternalServiceDiscovery_Service_extension.swift in Sources */, FE74D510234A4E1F001A925B /* ChatTableViewSystemCell.swift in Sources */, FE507A241CDB7B3B001A015C /* GlobalSplitViewController.swift in Sources */, FE233CD521E6846E00099281 /* CameraPreviewView.swift in Sources */, FE11D2E026616A4F00CC883F /* DBRosterStore.swift in Sources */, FE507A261CDB7B3B001A015C /* AvatarManager.swift in Sources */, FEA308D01F27A063002EF4C0 /* NavigationControllerWrappingSegue.swift in Sources */, FEA7BF5B21E50C5800D9E36C /* JingleManager.swift in Sources */, FE11D35226623D6800CC883F /* ConversationEntryState.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ FE4DDF5C1F39E0B500A4CE5A /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FE4DDF521F39E0B500A4CE5A /* Siskin IM - Share */; targetProxy = FE4DDF5B1F39E0B500A4CE5A /* PBXContainerItemProxy */; }; FE759FA72370ACA4001E78D9 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FE759FA02370ACA4001E78D9 /* NotificationService */; targetProxy = FE759FA62370ACA4001E78D9 /* PBXContainerItemProxy */; }; FE759FCB2370B2A4001E78D9 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FE759FC42370B2A4001E78D9 /* Shared */; targetProxy = FE759FCA2370B2A4001E78D9 /* PBXContainerItemProxy */; }; FE759FD42370B2F2001E78D9 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FE759FC42370B2A4001E78D9 /* Shared */; targetProxy = FE759FD32370B2F2001E78D9 /* PBXContainerItemProxy */; }; FE759FE52371C83D001E78D9 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FE759FC42370B2A4001E78D9 /* Shared */; targetProxy = FE759FE42371C83D001E78D9 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ FE4DDF571F39E0B500A4CE5A /* MainInterface.storyboard */ = { isa = PBXVariantGroup; children = ( FE4DDF581F39E0B500A4CE5A /* Base */, FEBA831726F76B0800347D89 /* pl */, FE2A00BD28048D6C00AF0152 /* en */, FEF97CF028770E60008CF411 /* de */, FEF97CFA28770E83008CF411 /* es */, ); name = MainInterface.storyboard; sourceTree = ""; }; FE94E52A1CCBA74F00FAE755 /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( FE94E52B1CCBA74F00FAE755 /* Base */, FEBA830F26F76ADA00347D89 /* pl */, FE06920227F4D910004E341E /* en */, FEF97CE828770E5C008CF411 /* de */, FEF97CF228770E7F008CF411 /* es */, ); name = Main.storyboard; sourceTree = ""; }; FE94E52F1CCBA74F00FAE755 /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( FE94E5301CCBA74F00FAE755 /* Base */, FEBA830E26F76ADA00347D89 /* pl */, FEF97CE728770E5C008CF411 /* de */, FEF97CF128770E7F008CF411 /* es */, ); name = LaunchScreen.storyboard; sourceTree = ""; }; FEBA82FB26F76A8000347D89 /* VoIP.storyboard */ = { isa = PBXVariantGroup; children = ( FEBA82FA26F76A8000347D89 /* Base */, FEBA831026F76ADA00347D89 /* pl */, FE06920027F4D8D8004E341E /* en */, FEF97CE928770E5C008CF411 /* de */, FEF97CF328770E80008CF411 /* es */, ); name = VoIP.storyboard; sourceTree = ""; }; FEBA82FE26F76A8700347D89 /* Groupchat.storyboard */ = { isa = PBXVariantGroup; children = ( FEBA82FD26F76A8700347D89 /* Base */, FEBA831126F76ADA00347D89 /* pl */, FE0691FE27F4D84B004E341E /* en */, FEF97CEA28770E5C008CF411 /* de */, FEF97CF428770E80008CF411 /* es */, ); name = Groupchat.storyboard; sourceTree = ""; }; FEBA830126F76A8D00347D89 /* Info.storyboard */ = { isa = PBXVariantGroup; children = ( FEBA830026F76A8D00347D89 /* Base */, FEBA831226F76B0800347D89 /* pl */, FE06920427F4D948004E341E /* en */, FEF97CEB28770E5C008CF411 /* de */, FEF97CF528770E80008CF411 /* es */, ); name = Info.storyboard; sourceTree = ""; }; FEBA830426F76A9300347D89 /* Settings.storyboard */ = { isa = PBXVariantGroup; children = ( FEBA830326F76A9300347D89 /* Base */, FEBA831326F76B0800347D89 /* pl */, FE06920627F4D97E004E341E /* en */, FEF97CEC28770E5C008CF411 /* de */, FEF97CF628770E80008CF411 /* es */, ); name = Settings.storyboard; sourceTree = ""; }; FEBA830726F76A9800347D89 /* Account.storyboard */ = { isa = PBXVariantGroup; children = ( FEBA830626F76A9800347D89 /* Base */, FEBA831426F76B0800347D89 /* pl */, FE06920827F4D9BE004E341E /* en */, FEF97CED28770E5C008CF411 /* de */, FEF97CF728770E80008CF411 /* es */, ); name = Account.storyboard; sourceTree = ""; }; FEBA830A26F76A9C00347D89 /* Conversation.storyboard */ = { isa = PBXVariantGroup; children = ( FEBA830926F76A9C00347D89 /* Base */, FEBA831526F76B0800347D89 /* pl */, FE06920A27F4D9D6004E341E /* en */, FEF97CEE28770E5C008CF411 /* de */, FEF97CF828770E80008CF411 /* es */, ); name = Conversation.storyboard; sourceTree = ""; }; FEBA830D26F76AA200347D89 /* MIX.storyboard */ = { isa = PBXVariantGroup; children = ( FEBA830C26F76AA200347D89 /* Base */, FEBA831626F76B0800347D89 /* pl */, FE06920C27F4DA12004E341E /* en */, FEF97CEF28770E5C008CF411 /* de */, FEF97CF928770E80008CF411 /* es */, ); name = MIX.storyboard; sourceTree = ""; }; FEBA835626F8DF1300347D89 /* Localizable.strings */ = { isa = PBXVariantGroup; children = ( FEBA835526F8DF1300347D89 /* pl */, FE06920D27F4DA58004E341E /* en */, FE4744062877120E00B28980 /* de */, FE4744072877121100B28980 /* es */, ); name = Localizable.strings; path = localization; sourceTree = ""; }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ FE4DDF5F1F39E0B600A4CE5A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon-Simple"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CODE_SIGN_ENTITLEMENTS = "SiskinIM - Share/SiskinIM - Share.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = YBEYW6E35C; ENABLE_BITCODE = NO; HEADER_SEARCH_PATHS = ( "$(SDK_DIR)/usr/include", "$(SDK_DIR)/usr/include/libxml2", ); INFOPLIST_FILE = "SiskinIM - Share/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); MARKETING_VERSION = 7.4.0; PRODUCT_BUNDLE_IDENTIFIER = "org.tigase.messenger.mobile.Tigase-Messenger---Share"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_VERSION = 5.0; }; name = Debug; }; FE4DDF601F39E0B600A4CE5A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon-Simple"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CODE_SIGN_ENTITLEMENTS = "SiskinIM - Share/SiskinIM - Share.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = YBEYW6E35C; ENABLE_BITCODE = NO; HEADER_SEARCH_PATHS = ( "$(SDK_DIR)/usr/include", "$(SDK_DIR)/usr/include/libxml2", ); INFOPLIST_FILE = "SiskinIM - Share/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); MARKETING_VERSION = 7.4.0; PRODUCT_BUNDLE_IDENTIFIER = "org.tigase.messenger.mobile.Tigase-Messenger---Share"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; }; name = Release; }; FE759FA92370ACA4001E78D9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = YBEYW6E35C; ENABLE_BITCODE = NO; GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = ( "$(SDK_DIR)/usr/include", "$(SDK_DIR)/usr/include/libxml2/**", ); INFOPLIST_FILE = NotificationService/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); MARKETING_VERSION = 7.4.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.tigase.messenger.mobile.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; FE759FAA2370ACA4001E78D9 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = YBEYW6E35C; ENABLE_BITCODE = NO; GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = ( "$(SDK_DIR)/usr/include", "$(SDK_DIR)/usr/include/libxml2/**", ); INFOPLIST_FILE = NotificationService/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); MARKETING_VERSION = 7.4.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.tigase.messenger.mobile.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; FE759FCF2370B2A4001E78D9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = ""; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = YBEYW6E35C; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_BITCODE = NO; GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = ( "$(SDK_DIR)/usr/include", "$(SDK_DIR)/usr/include/libxml2/**", ); INFOPLIST_FILE = Shared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.tigase.messenger.mobile.Shared; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; }; name = Debug; }; FE759FD02370B2A4001E78D9 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = ""; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = YBEYW6E35C; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_BITCODE = NO; GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = ( "$(SDK_DIR)/usr/include", "$(SDK_DIR)/usr/include/libxml2/**", ); INFOPLIST_FILE = Shared/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.tigase.messenger.mobile.Shared; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; }; name = Release; }; FE94E5491CCBA74F00FAE755 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; FRAMEWORK_SEARCH_PATHS = ""; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_OBJC_BRIDGING_HEADER = "SiskinIM/SiskinIM-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; USER_HEADER_SEARCH_PATHS = ""; }; name = Debug; }; FE94E54A1CCBA74F00FAE755 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; FRAMEWORK_SEARCH_PATHS = ""; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; ONLY_ACTIVE_ARCH = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OBJC_BRIDGING_HEADER = "SiskinIM/SiskinIM-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; USER_HEADER_SEARCH_PATHS = ""; VALIDATE_PRODUCT = YES; }; name = Release; }; FE94E54C1CCBA74F00FAE755 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon-Simple"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = SiskinIM.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = YBEYW6E35C; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)", "$(PROJECT_DIR)/Frameworks", "$(PROJECT_DIR)/Frameworks/__MACOSX", ); HEADER_SEARCH_PATHS = ( "$(SDK_DIR)/usr/include", "$(SDK_DIR)/usr/include/libxml2/**", ); INFOPLIST_FILE = SiskinIM/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 7.4.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.tigase.messenger.mobile; PRODUCT_NAME = Siskin; PROVISIONING_PROFILE = ""; STRIP_INSTALLED_PRODUCT = NO; SWIFT_INCLUDE_PATHS = "$(SRCROOT)/Modules/"; SWIFT_VERSION = 5.0; }; name = Debug; }; FE94E54D1CCBA74F00FAE755 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon-Simple"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = SiskinIM.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = YBEYW6E35C; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)", "$(PROJECT_DIR)/Frameworks", "$(PROJECT_DIR)/Frameworks/__MACOSX", ); HEADER_SEARCH_PATHS = ( "$(SDK_DIR)/usr/include", "$(SDK_DIR)/usr/include/libxml2/**", ); INFOPLIST_FILE = SiskinIM/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 7.4.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.tigase.messenger.mobile; PRODUCT_NAME = Siskin; PROVISIONING_PROFILE = ""; STRIP_INSTALLED_PRODUCT = NO; SWIFT_INCLUDE_PATHS = "$(SRCROOT)/Modules/"; SWIFT_VERSION = 5.0; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ FE4DDF5E1F39E0B600A4CE5A /* Build configuration list for PBXNativeTarget "Siskin IM - Share" */ = { isa = XCConfigurationList; buildConfigurations = ( FE4DDF5F1F39E0B600A4CE5A /* Debug */, FE4DDF601F39E0B600A4CE5A /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; FE759FAB2370ACA4001E78D9 /* Build configuration list for PBXNativeTarget "NotificationService" */ = { isa = XCConfigurationList; buildConfigurations = ( FE759FA92370ACA4001E78D9 /* Debug */, FE759FAA2370ACA4001E78D9 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; FE759FCE2370B2A4001E78D9 /* Build configuration list for PBXNativeTarget "Shared" */ = { isa = XCConfigurationList; buildConfigurations = ( FE759FCF2370B2A4001E78D9 /* Debug */, FE759FD02370B2A4001E78D9 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; FE94E51C1CCBA74F00FAE755 /* Build configuration list for PBXProject "SiskinIM" */ = { isa = XCConfigurationList; buildConfigurations = ( FE94E5491CCBA74F00FAE755 /* Debug */, FE94E54A1CCBA74F00FAE755 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; FE94E54B1CCBA74F00FAE755 /* Build configuration list for PBXNativeTarget "Siskin IM" */ = { isa = XCConfigurationList; buildConfigurations = ( FE94E54C1CCBA74F00FAE755 /* Debug */, FE94E54D1CCBA74F00FAE755 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ FE392E01289AF3B4006AA914 /* XCRemoteSwiftPackageReference "MartinOMEMO" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/tigase/MartinOMEMO"; requirement = { kind = upToNextMinorVersion; minimumVersion = 2.2.3; }; }; FEA5189D2661573F00523EF2 /* XCRemoteSwiftPackageReference "tigase-sqlite3.swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/tigase/tigase-sqlite3.swift.git"; requirement = { kind = upToNextMinorVersion; minimumVersion = 1.0.0; }; }; FEA518C0266165A100523EF2 /* XCRemoteSwiftPackageReference "tigase-logging.swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/tigase/tigase-logging.swift.git"; requirement = { kind = upToNextMinorVersion; minimumVersion = 1.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ FE392E02289AF47D006AA914 /* MartinOMEMO */ = { isa = XCSwiftPackageProductDependency; package = FE392E01289AF3B4006AA914 /* XCRemoteSwiftPackageReference "MartinOMEMO" */; productName = MartinOMEMO; }; FEA518B0266159B900523EF2 /* TigaseSQLite3 */ = { isa = XCSwiftPackageProductDependency; package = FEA5189D2661573F00523EF2 /* XCRemoteSwiftPackageReference "tigase-sqlite3.swift" */; productName = TigaseSQLite3; }; FEA518C1266165A100523EF2 /* TigaseLogging */ = { isa = XCSwiftPackageProductDependency; package = FEA518C0266165A100523EF2 /* XCRemoteSwiftPackageReference "tigase-logging.swift" */; productName = TigaseLogging; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = FE94E5191CCBA74F00FAE755 /* Project object */; } ================================================ FILE: SiskinIM.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: SiskinIM.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: SiskinIM.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme ================================================ ================================================ FILE: pom.xml ================================================ tigase tigase-projects-parent 1.0.4 pom . Tigase_SiskinIM ./Documentation generate-resources 4.0.0 docs Siskin IM Documentation pom iosdocs v1.0 com.mycila license-maven-plugin true **/* format tigase https://maven-repo.tigase.org/repository/tigase ================================================ FILE: siskin-im.doap ================================================ Siskin IM Siskin IM 2016-05-05 iOS XMPP Client Siskin IM is an open source XMPP client for iPhone and iPad en Swift iOS partial 4.9 XEP-0004: Data Forms wontfix 1.4 complete 2.5rc3 complete 1.34.2 complete 1.2 complete 1.1 wontfix 1.2 complete 1.2 complete 1.1.4 partial 2.1 Only "composing" is supported complete 1.1 complete 1.5.2 complete 1.1 complete 1.2.1 Avatar, Bookmarks, OMEMO complete 1.1.2 A/V calls complete 1.2.1 complete 1.1 complete 1.1 complete 1.4.0 complete 1.3 complete 1.6 complete 2.0.1 complete 2.0.1 complete 0.7 complete 1.1 complete 1.3 complete 1.0 complete 1.2 complete 1.0.1 complete 1.0.1 complete 1.0 complete 1.2.0 complete 0.6.3 complete 1.0.0 complete 0.3 complete 1.0.0 complete 1.0.0 complete 0.3.0 complete 0.3.0 complete 0.4.0 complete 1.0.0 complete 1.1.0 complete 0.14.6 complete 0.3.0 complete 0.2.1 complete 0.5.1 complete 0.3.0 partial 0.1.2 Only avatars, invitations and message retractions are supported. wontfix 0.2.0 complete 0.3.0 complete 0.1.0 7.1 2022-02-11 ================================================ FILE: swiftScript.swift ================================================ # # swiftScript.swift # # Tigase iOS Messenger # Copyright (C) 2016 "Tigase, Inc." # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, # or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. Look for COPYING file in the top folder. # If not, see http://www.gnu.org/licenses/. # # Script responsible for marking TODO: and FIXME: as warinings in comments during compilation #import Foundation TAGS="TODO:|FIXME:" find "${SRCROOT}" \( \( -name "*.h" -or -name "*.m" -or -name "*.swift" \) -and -not -name "swiftScript.swift" \) -print0 | xargs -0 egrep --with-filename --line-number --only-matching "($TAGS).*\$" | perl -p -e "s/($TAGS)/ warning: \$1/" ================================================ FILE: trim.sh ================================================ FRAMEWORK=$1 echo "Trimming $FRAMEWORK..." FRAMEWORK_EXECUTABLE_PATH="${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/$FRAMEWORK.framework/$FRAMEWORK" EXTRACTED_ARCHS=() for ARCH in $ARCHS do echo "Extracting $ARCH..." lipo -extract "$ARCH" "$FRAMEWORK_EXECUTABLE_PATH" -o "$FRAMEWORK_EXECUTABLE_PATH-$ARCH" EXTRACTED_ARCHS+=("$FRAMEWORK_EXECUTABLE_PATH-$ARCH") done echo "Merging binaries..." lipo -o "$FRAMEWORK_EXECUTABLE_PATH-merged" -create "${EXTRACTED_ARCHS[@]}" rm "${EXTRACTED_ARCHS[@]}" rm "$FRAMEWORK_EXECUTABLE_PATH" mv "$FRAMEWORK_EXECUTABLE_PATH-merged" "$FRAMEWORK_EXECUTABLE_PATH" echo "Done." ================================================ FILE: update-frameworks.sh ================================================ OPENSSL_FRAMEWORK="OpenSSL.xcframework" OPENSSL_VERSION="1.1.1400" OPENSSL_CHECKSUM="ad34827d95048a4b16c66614428e6f077d9a9e4892b8cf2c9fa66ac7ac93f916" OPENSSL_REPO="tigase/openssl-swiftpm" WEBRTC_FRAMEWORK="WebRTC.xcframework" WEBRTC_VERSION="M122" WEBRTC_CHECKSUM="ede2726a0540377b911bba845c2910704aee3203e0873550fda70783cc12daf1" WEBRTC_REPO="tigase/webrtc-swiftpm" testChecksum () { retval=0 local CHECKSUM=($(shasum -a 256 "$1.zip")) if [ "$2" != "$CHECKSUM" ]; then echo "Checksum of $1 does not match, removing file" retval=1 fi return "$retval" } downloadFile () { echo "Downloading file $1..." curl -L "https://github.com/$2/releases/download/$3/$1.zip" -o "$1.zip" retval=$? return "$retval" } downloadIfNeeded () { testChecksum $1 $2 result=$? if [ "$result" != "0" ]; then rm -rf "$1" rm "$1.zip" downloadFile $1 $3 $4 result=$? if [ "$result" = "0" ]; then testChecksum $1 $2 result=$? if [ "$result" != "0" ]; then rm "$1" echo "Invalid checksum of downloaded file $1" exit $result fi unzip -q "$1.zip" else echo "Could not download file $1" exit $result fi fi } cd Frameworks downloadIfNeeded $OPENSSL_FRAMEWORK $OPENSSL_CHECKSUM $OPENSSL_REPO $OPENSSL_VERSION result=$? if [ "$result" != "0" ]; then echo "Could not update $OPENSSL_FRAMEWORK"; exit $result; fi downloadIfNeeded $WEBRTC_FRAMEWORK $WEBRTC_CHECKSUM $WEBRTC_REPO $WEBRTC_VERSION result=$? if [ "$result" != "0" ]; then echo "Could not update $WEBRTC_FRAMEWORK"; exit $result; fi