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
================================================
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
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