Improved file sharing and added link previews #siskinim-178

This commit is contained in:
Andrzej Wójcik 2020-01-04 19:29:24 +01:00
parent f21170e44f
commit 8011bae7cb
No known key found for this signature in database
GPG key ID: 2BE28BB9C1B5FF02
45 changed files with 3290 additions and 1167 deletions

View file

@ -25,7 +25,7 @@ import TigaseSwift
public class DBSchemaManager {
static let CURRENT_VERSION = 8;
static let CURRENT_VERSION = 9;
fileprivate let dbConnection: DBConnection;

7
Shared/db-schema-9.sql Normal file
View file

@ -0,0 +1,7 @@
BEGIN;
ALTER TABLE chat_history ADD COLUMN appendix TEXT;
COMMIT;
PRAGMA user_version = 9;

View file

@ -106,29 +106,60 @@ class ShareViewController: SLComposeServiceViewController {
override func didSelectPost() {
if let provider = (self.extensionContext!.inputItems.first as? NSExtensionItem)?.attachments?.first {
if provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
provider.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil, completionHandler: { (value, error) in
self.shareText(url: value as! URL);
})
} else {
if let type = [kUTTypeFileURL, kUTTypeImage, kUTTypeMovie].filter({ (it) -> Bool in
return provider.hasItemConformingToTypeIdentifier(it as String);
}).first {
provider.loadItem(forTypeIdentifier: type as String, options: nil, completionHandler: { (item, error) in
if let localUrl = item as? URL {
let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, localUrl.pathExtension as CFString, nil)?.takeRetainedValue();
self.upload(localUrl: localUrl, type: uti != nil ? (UTTypeCopyPreferredTagWithClass(uti!, kUTTagClassMIMEType)?.takeRetainedValue() as String?) : nil, handler: {(remoteUrl) in
guard remoteUrl != nil else {
self.showAlert(title: "Failure", message: "Please try again later.");
return;
if provider.hasItemConformingToTypeIdentifier(kUTTypeFileURL as String) {
provider.loadItem(forTypeIdentifier: kUTTypeFileURL as String, options: nil, completionHandler: { (item, error) in
if let localUrl = item as? URL {
let uti = try? localUrl.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier;
let mimeType = uti != nil ? (UTTypeCopyPreferredTagWithClass(uti! as CFString, kUTTagClassMIMEType)?.takeRetainedValue() as String?) : nil;
let size = try? FileManager.default.attributesOfItem(atPath: localUrl.path)[FileAttributeKey.size] as? UInt64;
self.upload(localUrl: localUrl, type: mimeType, handler: {(remoteUrl) in
guard remoteUrl != nil else {
self.showAlert(title: "Failure", message: "Please try again later.");
return;
}
if self.sharedDefaults!.integer(forKey: "fileDownloadSizeLimit") > 0 {
let hash = Digest.sha1.digest(toHex: remoteUrl!.absoluteString.data(using: .utf8)!)!;
var params: [String: Any] = [
"jids": self.recipients.map({ $0.bareJid.stringValue }),
"name": localUrl.lastPathComponent,
"timestamp": Date()
];
if mimeType != nil {
params["mimeType"] = mimeType;
}
self.shareText(url: remoteUrl!);
});
} else {
self.showAlert(title: "Failure", message: "Please try again later.");
}
})
}
if size != nil {
params["size"] = Int(size!);
}
let localUploadDirUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.siskinim.shared")!.appendingPathComponent("upload", isDirectory: true);
if !FileManager.default.fileExists(atPath: localUploadDirUrl.path) {
try? FileManager.default.createDirectory(at: localUploadDirUrl, withIntermediateDirectories: true, attributes: nil);
}
do {
try FileManager.default.copyItem(at: localUrl, to: localUploadDirUrl.appendingPathComponent(hash, isDirectory: false));
self.sharedDefaults!.set(params as Any?, forKey: "upload-\(hash)");
} catch {
print("could not copy a file from:", localUrl, "to:", localUploadDirUrl)
}
}
self.share(url: nil, uploadedFileURL: remoteUrl);
});
} else {
self.showAlert(title: "Failure", message: "Please try again later.");
}
})
} else if provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
provider.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil, completionHandler: { (value, error) in
self.share(url: value as! URL, uploadedFileURL: nil);
})
// } else if provider.hasItemConformingToTypeIdentifier(kUTTypePlainText as String) {
// provider.loadItem(forTypeIdentifier: kUTTypePlainText as String, options: nil, completionHandler: { (item, error) in
// self.share(text: item as! String);
// });
// } else {
// self.showAlert(title: "Failure", message: "Please try again later.");
}
}
}
@ -218,7 +249,7 @@ class ShareViewController: SLComposeServiceViewController {
return nil;
}
func upload(localUrl: URL, type: String?, handler: (URL?)->Void) {
func upload(localUrl: URL, type: String?, handler: @escaping (URL?)->Void) {
let size = try! FileManager.default.attributesOfItem(atPath: localUrl.path)[FileAttributeKey.size] as! UInt64;
print("trying to upload", localUrl, "size", size, "type", type as Any);
if let httpModule: HttpFileUploadModule = self.xmppClient.modulesManager.getModule(HttpFileUploadModule.ID) {
@ -254,7 +285,7 @@ class ShareViewController: SLComposeServiceViewController {
self.showAlert(title: "Upload failed", message: "Upload to HTTP server failed.");
return;
}
self.shareText(url: slot.getUri);
handler(slot.getUri);
}.resume();
}, onError: {(errorCondition, message) in
self.showAlert(title: "Upload failed", message: message ?? "Please try again later.");
@ -271,24 +302,59 @@ class ShareViewController: SLComposeServiceViewController {
}
}
func shareText(url: URL) {
print("sharing", contentText as Any, url);
func share(text: String? = nil, url: URL? = nil, uploadedFileURL: URL? = nil) {
recipients.forEach { (recipient) in
let message = Message();
message.type = StanzaType.chat;
message.to = recipient;
message.body = contentText.isEmpty ? url.description : "\(contentText!) - \(url.description)";
message.oob = url.description;
xmppClient.context.writer?.write(message);
if !contentText.isEmpty || url != nil {
let message = Message();
message.type = StanzaType.chat;
message.to = recipient;
if let text = text {
message.body = contentText.isEmpty ? text : "\(contentText!) - \(text)";
} else if let url = url {
message.body = contentText.isEmpty ? url.description : "\(contentText!) - \(url.description)";
} else {
message.body = contentText;
}
xmppClient.context.writer?.write(message);
}
if let url = uploadedFileURL {
let message = Message();
message.type = .chat;
message.to = recipient;
message.oob = url.description;
xmppClient.context.writer?.write(message);
}
}
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.2, execute: {
self.xmppClient.disconnect();
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil);
});
}
// func shareText(url: URL?) {
// print("sharing", contentText as Any, url);
//
// recipients.forEach { (recipient) in
// let message = Message();
// message.type = StanzaType.chat;
// message.to = recipient;
// if let url = url {
// message.body = contentText.isEmpty ? url.description : "\(contentText!) - \(url.description)";
// message.oob = url.description;
// }
// xmppClient.context.writer?.write(message);
// }
//
// DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.2, execute: {
// self.xmppClient.disconnect();
// self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil);
// });
// }
class ShareEventHandler: EventHandler {
weak var controller: ShareViewController?;

View file

@ -35,6 +35,7 @@
FE4071E421E2605900F09B58 /* VideoCallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4071E321E2605900F09B58 /* VideoCallController.swift */; };
FE4071E621E262D900F09B58 /* VoIP.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FE4071E521E262D900F09B58 /* VoIP.storyboard */; };
FE4071E821E2653700F09B58 /* RoundButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4071E721E2653700F09B58 /* RoundButton.swift */; };
FE43E43823BF3DE80079BD9B /* ChatAttachementsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE43E43723BF3DE80079BD9B /* ChatAttachementsController.swift */; };
FE43EB551F3CC55900A4CAAD /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE43EB541F3CC55900A4CAAD /* ImageCache.swift */; };
FE43EB571F3DBAAE00A4CAAD /* BaseChatViewController_PreviewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE43EB561F3DBAAE00A4CAAD /* BaseChatViewController_PreviewExtension.swift */; };
FE4DDF561F39E0B500A4CE5A /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4DDF551F39E0B500A4CE5A /* ShareViewController.swift */; };
@ -106,6 +107,8 @@
FE75A008237585DC001E78D9 /* NotificationEncryptionKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE75A007237585DC001E78D9 /* NotificationEncryptionKeys.swift */; };
FE75A0102375F338001E78D9 /* PushEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE75A00E2375F324001E78D9 /* PushEventHandler.swift */; };
FE75A0122376E73C001E78D9 /* SiskinPushNotificationsModuleProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE75A0112376E73C001E78D9 /* SiskinPushNotificationsModuleProvider.swift */; };
FE7D293423B919FF001A877D /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7D293323B919FF001A877D /* DownloadManager.swift */; };
FE7D293623BB5E0A001A877D /* LinkPreviewChatTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7D293523BB5E0A001A877D /* LinkPreviewChatTableViewCell.swift */; };
FE7F645B1D281B1C00B9DF56 /* DBCapabilitiesCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7F645A1D281B1C00B9DF56 /* DBCapabilitiesCache.swift */; };
FE7F9303200FD5AC004C6195 /* AccountManagerScramSaltedPasswordCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7F9302200FD5AC004C6195 /* AccountManagerScramSaltedPasswordCache.swift */; };
FE80BDA81D928AD2001914B0 /* TigaseSwift.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FE80BDA71D928AD2001914B0 /* TigaseSwift.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@ -123,6 +126,7 @@
FE9E136F1F26049A005C0EE5 /* NotificationSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9E136E1F26049A005C0EE5 /* NotificationSettingsViewController.swift */; };
FE9E13711F2606E9005C0EE5 /* ContactsSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9E13701F2606E9005C0EE5 /* ContactsSettingsViewController.swift */; };
FE9E13731F260B33005C0EE5 /* StepperTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9E13721F260B33005C0EE5 /* StepperTableViewCell.swift */; };
FE9EA16B23BF9DB2008C401A /* ChatAttachementsCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9EA16A23BF9DB2008C401A /* ChatAttachementsCellView.swift */; };
FEA308D01F27A063002EF4C0 /* NavigationControllerWrappingSegue.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA308CF1F27A063002EF4C0 /* NavigationControllerWrappingSegue.swift */; };
FEA370BE1F3F3F8B0050CBAC /* TigaseSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE80BDA71D928AD2001914B0 /* TigaseSwift.framework */; };
FEA7BF5B21E50C5800D9E36C /* JingleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA7BF5A21E50C5800D9E36C /* JingleManager.swift */; };
@ -138,6 +142,15 @@
FEC514241CEB2FF7003AF765 /* MucJoinViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC514231CEB2FF7003AF765 /* MucJoinViewController.swift */; };
FEC514261CEB74F8003AF765 /* BaseChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC514251CEB74F8003AF765 /* BaseChatViewController.swift */; };
FEC514281CEB82E9003AF765 /* MucChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC514271CEB82E9003AF765 /* MucChatViewController.swift */; };
FECEF28C23B79151007EC323 /* ChatEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECEF28B23B79151007EC323 /* ChatEntry.swift */; };
FECEF28E23B7919E007EC323 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECEF28D23B7919E007EC323 /* ChatMessage.swift */; };
FECEF29023B7926E007EC323 /* ChatAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECEF28F23B7926E007EC323 /* ChatAttachment.swift */; };
FECEF29223B792C6007EC323 /* ChatLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECEF29123B792C6007EC323 /* ChatLinkPreview.swift */; };
FECEF29423B7933A007EC323 /* MetadataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECEF29323B7933A007EC323 /* MetadataCache.swift */; };
FECEF29623B7B076007EC323 /* DownloadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECEF29523B7B076007EC323 /* DownloadStore.swift */; };
FECEF29823B7B838007EC323 /* db-schema-9.sql in Resources */ = {isa = PBXBuildFile; fileRef = FECEF29723B7B838007EC323 /* db-schema-9.sql */; };
FECEF29B23B7BC02007EC323 /* BaseChatTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECEF29923B7BBD3007EC323 /* BaseChatTableViewCell.swift */; };
FECEF29E23B7C390007EC323 /* AttachmentChatTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECEF29C23B7C36F007EC323 /* AttachmentChatTableViewCell.swift */; };
FED353852270BFE600B69C53 /* openssl.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FED353822270BFD300B69C53 /* openssl.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
FED353862270BFE600B69C53 /* TigaseSwiftOMEMO.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FED353732270BBA500B69C53 /* TigaseSwiftOMEMO.framework */; };
FED353872270BFE600B69C53 /* TigaseSwiftOMEMO.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FED353732270BBA500B69C53 /* TigaseSwiftOMEMO.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@ -298,6 +311,7 @@
FE4071E321E2605900F09B58 /* VideoCallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCallController.swift; sourceTree = "<group>"; };
FE4071E521E262D900F09B58 /* VoIP.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = VoIP.storyboard; sourceTree = "<group>"; };
FE4071E721E2653700F09B58 /* RoundButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundButton.swift; sourceTree = "<group>"; };
FE43E43723BF3DE80079BD9B /* ChatAttachementsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAttachementsController.swift; sourceTree = "<group>"; };
FE43EB541F3CC55900A4CAAD /* ImageCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; };
FE43EB561F3DBAAE00A4CAAD /* BaseChatViewController_PreviewExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseChatViewController_PreviewExtension.swift; sourceTree = "<group>"; };
FE4496C31F87911C009F649C /* db-schema-3.sql */ = {isa = PBXFileReference; lastKnownFileType = text; path = "db-schema-3.sql"; sourceTree = "<group>"; };
@ -361,6 +375,8 @@
FE75A007237585DC001E78D9 /* NotificationEncryptionKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationEncryptionKeys.swift; sourceTree = "<group>"; };
FE75A00E2375F324001E78D9 /* PushEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushEventHandler.swift; sourceTree = "<group>"; };
FE75A0112376E73C001E78D9 /* SiskinPushNotificationsModuleProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiskinPushNotificationsModuleProvider.swift; sourceTree = "<group>"; };
FE7D293323B919FF001A877D /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = "<group>"; };
FE7D293523BB5E0A001A877D /* LinkPreviewChatTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewChatTableViewCell.swift; sourceTree = "<group>"; };
FE7F645A1D281B1C00B9DF56 /* DBCapabilitiesCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DBCapabilitiesCache.swift; sourceTree = "<group>"; };
FE7F9302200FD5AC004C6195 /* AccountManagerScramSaltedPasswordCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManagerScramSaltedPasswordCache.swift; sourceTree = "<group>"; };
FE80BDA71D928AD2001914B0 /* TigaseSwift.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TigaseSwift.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -384,6 +400,7 @@
FE9E136E1F26049A005C0EE5 /* NotificationSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationSettingsViewController.swift; sourceTree = "<group>"; };
FE9E13701F2606E9005C0EE5 /* ContactsSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactsSettingsViewController.swift; sourceTree = "<group>"; };
FE9E13721F260B33005C0EE5 /* StepperTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StepperTableViewCell.swift; sourceTree = "<group>"; };
FE9EA16A23BF9DB2008C401A /* ChatAttachementsCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAttachementsCellView.swift; sourceTree = "<group>"; };
FEA308CF1F27A063002EF4C0 /* NavigationControllerWrappingSegue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationControllerWrappingSegue.swift; sourceTree = "<group>"; };
FEA7BF5A21E50C5800D9E36C /* JingleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JingleManager.swift; sourceTree = "<group>"; };
FEA7BF5C21E50CAB00D9E36C /* JingleManager_Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JingleManager_Session.swift; sourceTree = "<group>"; };
@ -398,6 +415,15 @@
FEC514231CEB2FF7003AF765 /* MucJoinViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MucJoinViewController.swift; sourceTree = "<group>"; };
FEC514251CEB74F8003AF765 /* BaseChatViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseChatViewController.swift; sourceTree = "<group>"; };
FEC514271CEB82E9003AF765 /* MucChatViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MucChatViewController.swift; sourceTree = "<group>"; };
FECEF28B23B79151007EC323 /* ChatEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatEntry.swift; sourceTree = "<group>"; };
FECEF28D23B7919E007EC323 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = "<group>"; };
FECEF28F23B7926E007EC323 /* ChatAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAttachment.swift; sourceTree = "<group>"; };
FECEF29123B792C6007EC323 /* ChatLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLinkPreview.swift; sourceTree = "<group>"; };
FECEF29323B7933A007EC323 /* MetadataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataCache.swift; sourceTree = "<group>"; };
FECEF29523B7B076007EC323 /* DownloadStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadStore.swift; sourceTree = "<group>"; };
FECEF29723B7B838007EC323 /* db-schema-9.sql */ = {isa = PBXFileReference; lastKnownFileType = text; path = "db-schema-9.sql"; sourceTree = "<group>"; };
FECEF29923B7BBD3007EC323 /* BaseChatTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseChatTableViewCell.swift; sourceTree = "<group>"; };
FECEF29C23B7C36F007EC323 /* AttachmentChatTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentChatTableViewCell.swift; sourceTree = "<group>"; };
FED353732270BBA500B69C53 /* TigaseSwiftOMEMO.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TigaseSwiftOMEMO.framework; sourceTree = BUILT_PRODUCTS_DIR; };
FED353822270BFD300B69C53 /* openssl.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = openssl.framework; sourceTree = BUILT_PRODUCTS_DIR; };
FED353882270C1D000B69C53 /* DBOMEMOStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBOMEMOStore.swift; sourceTree = "<group>"; };
@ -537,6 +563,15 @@
FEF19F0D23479F4C005CFE9A /* ChatViewDataSource.swift */,
FEF19F152348E781005CFE9A /* BaseChatViewControllerWithDataSource.swift */,
FE74D50F234A4E1F001A925B /* ChatTableViewSystemCell.swift */,
FECEF28B23B79151007EC323 /* ChatEntry.swift */,
FECEF28D23B7919E007EC323 /* ChatMessage.swift */,
FECEF28F23B7926E007EC323 /* ChatAttachment.swift */,
FECEF29123B792C6007EC323 /* ChatLinkPreview.swift */,
FECEF29923B7BBD3007EC323 /* BaseChatTableViewCell.swift */,
FECEF29C23B7C36F007EC323 /* AttachmentChatTableViewCell.swift */,
FE7D293523BB5E0A001A877D /* LinkPreviewChatTableViewCell.swift */,
FE43E43723BF3DE80079BD9B /* ChatAttachementsController.swift */,
FE9EA16A23BF9DB2008C401A /* ChatAttachementsCellView.swift */,
);
path = chats;
sourceTree = "<group>";
@ -635,6 +670,9 @@
FE75A004237475CD001E78D9 /* MainNotificationManagerProvider.swift */,
FE75A0112376E73C001E78D9 /* SiskinPushNotificationsModuleProvider.swift */,
FEDC678D238B03C1005C0FAB /* AppStoryboard.swift */,
FECEF29323B7933A007EC323 /* MetadataCache.swift */,
FECEF29523B7B076007EC323 /* DownloadStore.swift */,
FE7D293323B919FF001A877D /* DownloadManager.swift */,
);
path = util;
sourceTree = "<group>";
@ -678,6 +716,7 @@
FE759FC72370B2A4001E78D9 /* Shared.h */,
FE759FC82370B2A4001E78D9 /* Info.plist */,
FE759FF823742AC1001E78D9 /* NotificationCategory.swift */,
FECEF29723B7B838007EC323 /* db-schema-9.sql */,
);
path = Shared;
sourceTree = "<group>";
@ -1008,6 +1047,7 @@
FE759FF22371F21C001E78D9 /* db-schema-6.sql in Resources */,
FE759FEF2371F21C001E78D9 /* db-schema-3.sql in Resources */,
FE759FED2371F213001E78D9 /* db-schema-2.sql in Resources */,
FECEF29823B7B838007EC323 /* db-schema-9.sql in Resources */,
FE759FF02371F21C001E78D9 /* db-schema-4.sql in Resources */,
FE759FF32371F21C001E78D9 /* db-schema-7.sql in Resources */,
FE759FF12371F21C001E78D9 /* db-schema-5.sql in Resources */,
@ -1123,7 +1163,9 @@
FE507A191CDB7B3B001A015C /* DBChatHistoryStore.swift in Sources */,
FEA8D65D1F2F6AF60077C12F /* VCardEntryTypeAwareTableViewCell.swift in Sources */,
FEDC6790238B05E4005C0FAB /* BlockedContactsController.swift in Sources */,
FECEF29423B7933A007EC323 /* MetadataCache.swift in Sources */,
FEB62C501DA80956001500D5 /* AvatarStore.swift in Sources */,
FE7D293423B919FF001A877D /* DownloadManager.swift in Sources */,
FE137A4821F6464D006B7F7C /* UIColor_mix.swift in Sources */,
FE507A1E1CDB7B3B001A015C /* RosterViewController.swift in Sources */,
FEDCBF691D9C53BA00AE9129 /* RosterProviderGrouped.swift in Sources */,
@ -1137,7 +1179,11 @@
FE507A1F1CDB7B3B001A015C /* AccountTableViewCell.swift in Sources */,
FEF19F122348A3B8005CFE9A /* AvatarEventHandler.swift in Sources */,
FE7F645B1D281B1C00B9DF56 /* DBCapabilitiesCache.swift in Sources */,
FECEF29023B7926E007EC323 /* ChatAttachment.swift in Sources */,
FE43E43823BF3DE80079BD9B /* ChatAttachementsController.swift in Sources */,
FEDE93891D081C3D00CA60A9 /* Settings.swift in Sources */,
FECEF29B23B7BC02007EC323 /* BaseChatTableViewCell.swift in Sources */,
FECEF29E23B7C390007EC323 /* AttachmentChatTableViewCell.swift in Sources */,
FED353892270C1D000B69C53 /* DBOMEMOStore.swift in Sources */,
FE8DD9C5221B153A0090F5AA /* InviteViewController.swift in Sources */,
FEB5EC9D1F6AE448007FE0E7 /* BaseChatViewControllerWithDataSourceContextMenuAndToolbar.swift in Sources */,
@ -1150,8 +1196,10 @@
FE759FFC23742CE5001E78D9 /* NotificationCenterDelegate.swift in Sources */,
FEF19F0C23476466005CFE9A /* MucEventHandler.swift in Sources */,
FE1DCCA21EA52CE200850563 /* DataFormController.swift in Sources */,
FE7D293623BB5E0A001A877D /* LinkPreviewChatTableViewCell.swift in Sources */,
FE6545641E9E8B67006A14AC /* ServerSelectorTableViewCell.swift in Sources */,
FE31291A22240BEB00A92863 /* PEPBookmarksModule_extension.swift in Sources */,
FECEF28E23B7919E007EC323 /* ChatMessage.swift in Sources */,
FE00157F2019090300490340 /* ExperimentalSettingsViewController.swift in Sources */,
FE75A006237475E2001E78D9 /* MainNotificationManagerProvider.swift in Sources */,
FEDE93871D07564F00CA60A9 /* SwitchTableViewCell.swift in Sources */,
@ -1172,6 +1220,7 @@
FE507A151CDB7B3B001A015C /* ChatsListTableViewCell.swift in Sources */,
FE43EB551F3CC55900A4CAAD /* ImageCache.swift in Sources */,
FE507A1C1CDB7B3B001A015C /* DBRosterStore.swift in Sources */,
FE9EA16B23BF9DB2008C401A /* ChatAttachementsCellView.swift in Sources */,
FEF19F102348A046005CFE9A /* PresenceRosterEventHandler.swift in Sources */,
FE94E5251CCBA74F00FAE755 /* AppDelegate.swift in Sources */,
FE137A4A21F72AEA006B7F7C /* Appearance.swift in Sources */,
@ -1199,7 +1248,10 @@
FE75A0102375F338001E78D9 /* PushEventHandler.swift in Sources */,
FEDCBF671D9C3EE700AE9129 /* RosterProviderFlat.swift in Sources */,
FE9E136D1F25F5F7005C0EE5 /* ChatSettingsViewController.swift in Sources */,
FECEF29623B7B076007EC323 /* DownloadStore.swift in Sources */,
FEF19F08234751FF005CFE9A /* ChatViewItemProtocol.swift in Sources */,
FECEF28C23B79151007EC323 /* ChatEntry.swift in Sources */,
FECEF29223B792C6007EC323 /* ChatLinkPreview.swift in Sources */,
FE9E13711F2606E9005C0EE5 /* ContactsSettingsViewController.swift in Sources */,
FE00157D2017617B00490340 /* StreamFeaturesCache.swift in Sources */,
FEDE938E1D09BB6200CA60A9 /* VCardEditBasicTableViewCell.swift in Sources */,

View file

@ -49,6 +49,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
}
try! DBConnection.migrateToGroupIfNeeded();
ImageCache.convertToAttachments();
RTCInitFieldTrialDictionary([:]);
RTCInitializeSSL();
RTCSetupInternalTracer();

View file

@ -1,11 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="vAU-gJ-Tx3">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="vAU-gJ-Tx3">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15510"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="collection view cell content view" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
@ -255,152 +256,16 @@
<rect key="frame" x="0.0" y="0.0" width="600" height="579"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<tableView contentMode="scaleToFill" alwaysBounceVertical="YES" keyboardDismissMode="interactive" dataMode="prototypes" style="plain" separatorStyle="default" allowsSelection="NO" allowsSelectionDuringEditing="YES" allowsMultipleSelectionDuringEditing="YES" rowHeight="44" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="Mpx-EA-82o">
<tableView contentMode="scaleToFill" alwaysBounceVertical="YES" keyboardDismissMode="interactive" dataMode="prototypes" style="plain" separatorStyle="default" allowsSelection="NO" allowsSelectionDuringEditing="YES" allowsMultipleSelectionDuringEditing="YES" rowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="Mpx-EA-82o">
<rect key="frame" x="0.0" y="0.0" width="600" height="579"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<gestureRecognizers/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="ChatTableViewCellOutgoing" selectionStyle="default" indentationWidth="10" reuseIdentifier="ChatTableViewCellOutgoing" id="R0Z-Gr-PEt" customClass="ChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="ChatTableViewCell" selectionStyle="default" indentationWidth="10" reuseIdentifier="ChatTableViewMessageCell" id="LeS-8c-zb0" customClass="ChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="28" width="600" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="R0Z-Gr-PEt" id="bgf-5y-X6E">
<rect key="frame" x="0.0" y="0.0" width="600" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<view contentMode="scaleToFill" horizontalHuggingPriority="252" verticalHuggingPriority="252" horizontalCompressionResistancePriority="752" verticalCompressionResistancePriority="752" misplaced="YES" translatesAutoresizingMaskIntoConstraints="NO" id="hny-rs-7Zh">
<rect key="frame" x="30" y="22" width="534" height="126"/>
<subviews>
<label opaque="NO" contentMode="right" horizontalHuggingPriority="253" verticalHuggingPriority="253" horizontalCompressionResistancePriority="755" verticalCompressionResistancePriority="755" text="Label" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="f43-Vp-tGL">
<rect key="frame" x="4" y="4" width="35.5" height="17"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" staticText="YES" allowsDirectInteraction="YES"/>
</accessibility>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" contentMode="scaleAspectFill" horizontalHuggingPriority="249" verticalHuggingPriority="249" verticalCompressionResistancePriority="754" translatesAutoresizingMaskIntoConstraints="NO" id="3Ea-xK-ixM">
<rect key="frame" x="0.0" y="25" width="43.5" height="0.0"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" image="YES" allowsDirectInteraction="YES"/>
<bool key="isElement" value="YES"/>
</accessibility>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="3Ea-xK-ixM" secondAttribute="height" multiplier="3:2" id="hlf-BV-Owd"/>
</constraints>
</imageView>
</subviews>
<color key="backgroundColor" red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="f43-Vp-tGL" firstAttribute="top" secondItem="hny-rs-7Zh" secondAttribute="top" constant="4" id="6n4-EN-Ael"/>
<constraint firstItem="f43-Vp-tGL" firstAttribute="leading" secondItem="hny-rs-7Zh" secondAttribute="leading" constant="4" id="Boi-Em-A8s"/>
<constraint firstAttribute="trailing" secondItem="f43-Vp-tGL" secondAttribute="trailing" constant="4" id="I3D-c0-KCd"/>
<constraint firstItem="3Ea-xK-ixM" firstAttribute="top" secondItem="f43-Vp-tGL" secondAttribute="bottom" constant="4" id="TtK-j8-Y3y"/>
<constraint firstAttribute="bottom" secondItem="3Ea-xK-ixM" secondAttribute="bottom" id="V95-PU-nYd"/>
<constraint firstAttribute="trailing" secondItem="3Ea-xK-ixM" secondAttribute="trailing" id="aTh-zW-uoH"/>
<constraint firstItem="3Ea-xK-ixM" firstAttribute="leading" secondItem="hny-rs-7Zh" secondAttribute="leading" id="ofS-wl-fYg"/>
<constraint firstItem="3Ea-xK-ixM" firstAttribute="width" secondItem="hny-rs-7Zh" secondAttribute="width" id="u3p-bz-AP5"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Temporary label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="eGw-mb-T3I">
<rect key="frame" x="484" y="29" width="91" height="13"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<color key="textColor" red="0.66666666666666663" green="0.66666666666666663" blue="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="eGw-mb-T3I" firstAttribute="top" secondItem="hny-rs-7Zh" secondAttribute="bottom" constant="2" id="1Ay-PZ-nuj"/>
<constraint firstAttribute="trailingMargin" secondItem="hny-rs-7Zh" secondAttribute="trailing" constant="2" id="2ch-AU-Mes"/>
<constraint firstItem="hny-rs-7Zh" firstAttribute="top" secondItem="bgf-5y-X6E" secondAttribute="top" constant="2" id="Cns-N6-ktg"/>
<constraint firstAttribute="bottom" secondItem="eGw-mb-T3I" secondAttribute="bottom" constant="2" id="fEf-hj-6qP"/>
<constraint firstItem="hny-rs-7Zh" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="bgf-5y-X6E" secondAttribute="leadingMargin" constant="45" id="rk5-8k-eSN"/>
<constraint firstAttribute="trailingMargin" secondItem="eGw-mb-T3I" secondAttribute="trailing" constant="5" id="xXB-8t-ADq" userLabel="ItrailingMargin = Timestamp View.trailing + 7"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="messageFrameView" destination="hny-rs-7Zh" id="a6I-ZA-Ebp"/>
<outlet property="messageTextView" destination="f43-Vp-tGL" id="lNK-Au-asZ"/>
<outlet property="previewView" destination="3Ea-xK-ixM" id="hRE-ra-hYs"/>
<outlet property="timestampView" destination="eGw-mb-T3I" id="s4e-RY-oTj"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="ChatTableViewCellIncoming" selectionStyle="default" indentationWidth="10" reuseIdentifier="ChatTableViewCellIncoming" id="2Y1-jf-e6K" customClass="ChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="72" width="600" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="2Y1-jf-e6K" id="ZfW-bW-Ai9">
<rect key="frame" x="0.0" y="0.0" width="600" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Temporary label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="RG5-TE-xcd">
<rect key="frame" x="41" y="27" width="91" height="14.5"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<color key="textColor" red="0.66666666666666663" green="0.66666666666666663" blue="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<imageView userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="rEA-Xx-pue" customClass="AvatarView" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="2" y="2" width="30" height="30"/>
<constraints>
<constraint firstAttribute="height" constant="30" id="42h-cw-1fr"/>
<constraint firstAttribute="width" constant="30" id="efb-t0-Vvi"/>
</constraints>
</imageView>
<view contentMode="scaleToFill" ambiguous="YES" misplaced="YES" translatesAutoresizingMaskIntoConstraints="NO" id="yI2-cn-AK5">
<rect key="frame" x="53" y="2" width="532" height="42"/>
<subviews>
<label opaque="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" misplaced="YES" text="Incoming message" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="vZk-ws-d3W">
<rect key="frame" x="8" y="8" width="516" height="14"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" contentMode="scaleAspectFill" horizontalHuggingPriority="249" verticalHuggingPriority="249" verticalCompressionResistancePriority="754" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="WF6-5A-KDy">
<rect key="frame" x="0.0" y="25" width="129" height="2"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" image="YES" allowsDirectInteraction="YES"/>
<bool key="isElement" value="YES"/>
</accessibility>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="WF6-5A-KDy" secondAttribute="height" multiplier="3:2" id="aBB-Vh-NnE"/>
</constraints>
</imageView>
</subviews>
<color key="backgroundColor" red="0.93725490199999995" green="0.93725490199999995" blue="0.95686274510000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="WF6-5A-KDy" firstAttribute="top" secondItem="vZk-ws-d3W" secondAttribute="bottom" constant="4" id="2xt-xN-U4q"/>
<constraint firstItem="vZk-ws-d3W" firstAttribute="leading" secondItem="yI2-cn-AK5" secondAttribute="leading" constant="4" id="AZY-J1-PVO"/>
<constraint firstAttribute="trailing" secondItem="WF6-5A-KDy" secondAttribute="trailing" id="BRj-r0-q5J"/>
<constraint firstItem="WF6-5A-KDy" firstAttribute="leading" secondItem="yI2-cn-AK5" secondAttribute="leading" id="P0d-a9-80D"/>
<constraint firstItem="WF6-5A-KDy" firstAttribute="width" secondItem="yI2-cn-AK5" secondAttribute="width" id="Xcl-VO-09L"/>
<constraint firstAttribute="bottom" secondItem="WF6-5A-KDy" secondAttribute="bottom" id="a8Z-L5-58h"/>
<constraint firstItem="vZk-ws-d3W" firstAttribute="top" secondItem="yI2-cn-AK5" secondAttribute="top" constant="4" id="on7-zT-6cX"/>
<constraint firstAttribute="trailing" secondItem="vZk-ws-d3W" secondAttribute="trailing" constant="4" id="rsw-5c-q1z"/>
</constraints>
</view>
</subviews>
<constraints>
<constraint firstItem="yI2-cn-AK5" firstAttribute="top" secondItem="ZfW-bW-Ai9" secondAttribute="top" constant="2" id="2GX-ZI-SJA"/>
<constraint firstItem="yI2-cn-AK5" firstAttribute="leading" secondItem="rEA-Xx-pue" secondAttribute="trailing" constant="4" id="C6x-N7-tdS"/>
<constraint firstItem="RG5-TE-xcd" firstAttribute="leading" secondItem="yI2-cn-AK5" secondAttribute="leading" constant="5" id="HWj-33-vQ9"/>
<constraint firstItem="rEA-Xx-pue" firstAttribute="leading" secondItem="ZfW-bW-Ai9" secondAttribute="leading" constant="2" id="Kyo-Ot-saa"/>
<constraint firstAttribute="trailingMargin" relation="greaterThanOrEqual" secondItem="yI2-cn-AK5" secondAttribute="trailing" constant="30" id="Wd8-VS-XIp"/>
<constraint firstAttribute="bottom" secondItem="RG5-TE-xcd" secondAttribute="bottom" constant="2" id="ggs-F0-ci2"/>
<constraint firstItem="RG5-TE-xcd" firstAttribute="top" secondItem="yI2-cn-AK5" secondAttribute="bottom" constant="2" id="m8F-3t-Iup"/>
<constraint firstItem="rEA-Xx-pue" firstAttribute="top" secondItem="ZfW-bW-Ai9" secondAttribute="top" constant="2" id="wvS-IT-zEi"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="avatarView" destination="rEA-Xx-pue" id="bdR-Td-KOn"/>
<outlet property="messageFrameView" destination="yI2-cn-AK5" id="YC2-rj-90s"/>
<outlet property="messageTextView" destination="vZk-ws-d3W" id="gyT-Xt-57c"/>
<outlet property="previewView" destination="WF6-5A-KDy" id="Lkb-N1-sT1"/>
<outlet property="timestampView" destination="RG5-TE-xcd" id="Gns-G7-AEM"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="ChatTableViewCell" selectionStyle="default" indentationWidth="10" reuseIdentifier="ChatTableViewCell" rowHeight="71" id="LeS-8c-zb0" customClass="ChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="116" width="600" height="71"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="LeS-8c-zb0" id="Rua-Sn-7If">
<rect key="frame" x="0.0" y="0.0" width="600" height="71"/>
<rect key="frame" x="0.0" y="0.0" width="600" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="iWd-IQ-uK4" customClass="AvatarView" customModule="Siskin" customModuleProvider="target">
@ -410,36 +275,27 @@
<constraint firstAttribute="width" constant="30" id="djU-fH-v1r"/>
</constraints>
</imageView>
<label opaque="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Incoming message" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="oX2-6Y-0AV">
<rect key="frame" x="44" y="25" width="534" height="17"/>
<label contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Incoming message" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="oX2-6Y-0AV">
<rect key="frame" x="44" y="23" width="534" height="17"/>
<color key="backgroundColor" name="systemBackground"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" contentMode="scaleAspectFill" horizontalHuggingPriority="249" verticalHuggingPriority="249" verticalCompressionResistancePriority="754" translatesAutoresizingMaskIntoConstraints="NO" id="Mcv-2T-EnV">
<rect key="frame" x="44" y="42" width="534" height="25"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" image="YES" allowsDirectInteraction="YES"/>
<bool key="isElement" value="YES"/>
</accessibility>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="Mcv-2T-EnV" secondAttribute="height" multiplier="3:2" id="zeM-8O-iut"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="SyE-Pm-65B">
<rect key="frame" x="44" y="5" width="38" height="18"/>
<rect key="frame" x="44" y="5" width="38" height="16"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="15"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="600" text="Temporary label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="M8D-59-yPf">
<rect key="frame" x="504" y="6" width="90" height="17"/>
<rect key="frame" x="504" y="6" width="90" height="15"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="12"/>
<color key="textColor" red="0.66666666669999997" green="0.66666666669999997" blue="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="EYL-KQ-TJ8">
<rect key="frame" x="580" y="55" width="14" height="12"/>
<rect key="frame" x="580" y="28" width="14" height="12"/>
<constraints>
<constraint firstAttribute="width" constant="14" id="Qvz-MN-Dp0"/>
</constraints>
@ -452,57 +308,44 @@
<constraint firstItem="M8D-59-yPf" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="SyE-Pm-65B" secondAttribute="trailing" constant="10" id="193-IR-xui"/>
<constraint firstAttribute="trailing" secondItem="EYL-KQ-TJ8" secondAttribute="trailing" constant="6" id="8F2-PF-sGU"/>
<constraint firstItem="M8D-59-yPf" firstAttribute="bottom" secondItem="SyE-Pm-65B" secondAttribute="bottom" id="92y-3d-Kxk"/>
<constraint firstAttribute="bottom" secondItem="Mcv-2T-EnV" secondAttribute="bottom" constant="4" id="9jm-OK-0kv"/>
<constraint firstItem="EYL-KQ-TJ8" firstAttribute="bottom" secondItem="Mcv-2T-EnV" secondAttribute="bottom" id="FSJ-UH-Tnc"/>
<constraint firstAttribute="bottom" secondItem="oX2-6Y-0AV" secondAttribute="bottom" constant="4" id="A8Z-ca-ojt"/>
<constraint firstItem="SyE-Pm-65B" firstAttribute="leading" secondItem="iWd-IQ-uK4" secondAttribute="trailing" constant="8" id="SV8-FL-gWF"/>
<constraint firstItem="oX2-6Y-0AV" firstAttribute="bottom" secondItem="Mcv-2T-EnV" secondAttribute="top" id="U6t-4h-cq5"/>
<constraint firstItem="SyE-Pm-65B" firstAttribute="top" secondItem="iWd-IQ-uK4" secondAttribute="top" id="VDL-mB-wzM"/>
<constraint firstItem="oX2-6Y-0AV" firstAttribute="top" secondItem="SyE-Pm-65B" secondAttribute="bottom" constant="2" id="WtY-t2-H3k"/>
<constraint firstItem="EYL-KQ-TJ8" firstAttribute="leading" secondItem="oX2-6Y-0AV" secondAttribute="trailing" constant="2" id="aH6-OP-6JW"/>
<constraint firstItem="oX2-6Y-0AV" firstAttribute="leading" secondItem="SyE-Pm-65B" secondAttribute="leading" id="f3c-Fe-CVc"/>
<constraint firstAttribute="trailing" secondItem="M8D-59-yPf" secondAttribute="trailing" constant="6" id="gNr-48-DvR"/>
<constraint firstItem="EYL-KQ-TJ8" firstAttribute="bottom" secondItem="oX2-6Y-0AV" secondAttribute="bottom" id="iGB-8y-WXw"/>
<constraint firstItem="M8D-59-yPf" firstAttribute="top" secondItem="Rua-Sn-7If" secondAttribute="top" constant="6" id="kMt-uX-PXe"/>
<constraint firstItem="EYL-KQ-TJ8" firstAttribute="top" relation="greaterThanOrEqual" secondItem="SyE-Pm-65B" secondAttribute="bottom" constant="2" id="lCR-3N-UdJ"/>
<constraint firstItem="Mcv-2T-EnV" firstAttribute="leading" secondItem="oX2-6Y-0AV" secondAttribute="leading" id="no8-eR-p94"/>
<constraint firstItem="Mcv-2T-EnV" firstAttribute="trailing" secondItem="oX2-6Y-0AV" secondAttribute="trailing" priority="900" id="qah-eG-G1t"/>
<constraint firstItem="iWd-IQ-uK4" firstAttribute="top" secondItem="Rua-Sn-7If" secondAttribute="top" constant="5" id="sWq-aJ-A7j"/>
<constraint firstItem="EYL-KQ-TJ8" firstAttribute="leading" secondItem="Mcv-2T-EnV" secondAttribute="trailing" constant="2" id="vod-z6-fD2"/>
<constraint firstItem="iWd-IQ-uK4" firstAttribute="leading" secondItem="Rua-Sn-7If" secondAttribute="leading" constant="6" id="xIP-xZ-hud"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="avatarView" destination="iWd-IQ-uK4" id="rVR-v8-3Ti"/>
<outlet property="avatarView" destination="iWd-IQ-uK4" id="8Wt-Po-TGX"/>
<outlet property="messageTextView" destination="oX2-6Y-0AV" id="bgq-iM-elR"/>
<outlet property="nicknameView" destination="SyE-Pm-65B" id="MEc-zm-tZk"/>
<outlet property="previewView" destination="Mcv-2T-EnV" id="QqJ-WN-mpw"/>
<outlet property="stateView" destination="EYL-KQ-TJ8" id="Enz-dH-ul3"/>
<outlet property="timestampView" destination="M8D-59-yPf" id="SRn-Jc-eUW"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="ChatTableViewCellContinuation" selectionStyle="default" indentationWidth="10" reuseIdentifier="ChatTableViewCellContinuation" rowHeight="71" id="P2O-3P-PLi" customClass="ChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="187" width="600" height="71"/>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="ChatTableViewCellContinuation" selectionStyle="default" indentationWidth="10" reuseIdentifier="ChatTableViewMessageContinuationCell" id="P2O-3P-PLi" customClass="ChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="72" width="600" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="P2O-3P-PLi" id="Vhg-9a-XMw">
<rect key="frame" x="0.0" y="0.0" width="600" height="71"/>
<rect key="frame" x="0.0" y="0.0" width="600" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Incoming message" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="fL0-TM-g7L">
<rect key="frame" x="44" y="2" width="534" height="17"/>
<label contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Incoming message" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="fL0-TM-g7L">
<rect key="frame" x="44" y="2" width="534" height="38"/>
<color key="backgroundColor" name="systemBackground"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" contentMode="scaleAspectFill" horizontalHuggingPriority="249" verticalHuggingPriority="249" verticalCompressionResistancePriority="754" translatesAutoresizingMaskIntoConstraints="NO" id="FuR-Oq-Y9P">
<rect key="frame" x="44" y="19" width="534" height="48"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" image="YES" allowsDirectInteraction="YES"/>
<bool key="isElement" value="YES"/>
</accessibility>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="FuR-Oq-Y9P" secondAttribute="height" multiplier="3:2" id="Ql7-Rx-mID"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="e9t-6H-zJS">
<rect key="frame" x="580" y="55" width="14" height="12"/>
<rect key="frame" x="580" y="28" width="14" height="12"/>
<constraints>
<constraint firstAttribute="width" constant="14" id="b9J-S0-HiN"/>
</constraints>
@ -513,25 +356,126 @@
</subviews>
<constraints>
<constraint firstItem="e9t-6H-zJS" firstAttribute="top" relation="greaterThanOrEqual" secondItem="Vhg-9a-XMw" secondAttribute="top" constant="2" id="12w-w8-q7H"/>
<constraint firstItem="e9t-6H-zJS" firstAttribute="leading" secondItem="FuR-Oq-Y9P" secondAttribute="trailing" constant="2" id="Fm4-gy-brd"/>
<constraint firstItem="e9t-6H-zJS" firstAttribute="leading" secondItem="fL0-TM-g7L" secondAttribute="trailing" constant="2" id="5nC-ck-rlm"/>
<constraint firstAttribute="trailing" secondItem="e9t-6H-zJS" secondAttribute="trailing" constant="6" id="Hvt-wO-cXa"/>
<constraint firstItem="FuR-Oq-Y9P" firstAttribute="trailing" secondItem="fL0-TM-g7L" secondAttribute="trailing" priority="900" id="IN9-gQ-XVf"/>
<constraint firstAttribute="bottom" secondItem="FuR-Oq-Y9P" secondAttribute="bottom" constant="4" id="N5D-rN-yIW"/>
<constraint firstItem="fL0-TM-g7L" firstAttribute="top" secondItem="Vhg-9a-XMw" secondAttribute="top" constant="2" id="NUj-eJ-yHM"/>
<constraint firstItem="fL0-TM-g7L" firstAttribute="leading" secondItem="Vhg-9a-XMw" secondAttribute="leading" constant="44" id="PBP-uy-IJw"/>
<constraint firstItem="fL0-TM-g7L" firstAttribute="bottom" secondItem="FuR-Oq-Y9P" secondAttribute="top" id="VVJ-Ur-2YJ"/>
<constraint firstItem="FuR-Oq-Y9P" firstAttribute="leading" secondItem="fL0-TM-g7L" secondAttribute="leading" id="q0g-uo-QhW"/>
<constraint firstItem="e9t-6H-zJS" firstAttribute="bottom" secondItem="FuR-Oq-Y9P" secondAttribute="bottom" id="sgr-yR-730"/>
<constraint firstAttribute="bottom" secondItem="fL0-TM-g7L" secondAttribute="bottom" constant="4" id="i0l-5t-waC"/>
<constraint firstItem="e9t-6H-zJS" firstAttribute="bottom" secondItem="fL0-TM-g7L" secondAttribute="bottom" id="mwE-Pa-JeS"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="messageTextView" destination="fL0-TM-g7L" id="jSl-eI-CW6"/>
<outlet property="previewView" destination="FuR-Oq-Y9P" id="gz5-gw-icB"/>
<outlet property="stateView" destination="e9t-6H-zJS" id="1ud-1v-Thh"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" reuseIdentifier="ChatTableViewAttachmentCell" rowHeight="71" id="UMT-xD-QCU" customClass="AttachmentChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="116" width="600" height="71"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="UMT-xD-QCU" id="Lts-6C-Qxj">
<rect key="frame" x="0.0" y="0.0" width="600" height="71"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="iXm-m9-zoy" customClass="AvatarView" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="6" y="5" width="30" height="30"/>
<constraints>
<constraint firstAttribute="width" constant="30" id="Z33-6s-dKs"/>
<constraint firstAttribute="height" constant="30" id="ddh-22-4QN"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Nqc-Bg-dn1">
<rect key="frame" x="44" y="5" width="38" height="18"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="15"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="600" text="Temporary label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="9Oa-Mh-i12">
<rect key="frame" x="504" y="6" width="90" height="17"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="12"/>
<color key="textColor" red="0.66666666669999997" green="0.66666666669999997" blue="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Label" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="uez-Go-Gxg">
<rect key="frame" x="580" y="55" width="14" height="12"/>
<constraints>
<constraint firstAttribute="width" constant="14" id="UlC-be-8lb"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="10"/>
<color key="textColor" name="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<view opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="b6A-lN-VQE">
<rect key="frame" x="44" y="25" width="534" height="42"/>
</view>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="9Oa-Mh-i12" secondAttribute="trailing" constant="6" id="0G9-3X-YzF"/>
<constraint firstAttribute="bottom" secondItem="b6A-lN-VQE" secondAttribute="bottom" constant="4" id="1vw-r8-gN5"/>
<constraint firstItem="iXm-m9-zoy" firstAttribute="top" secondItem="Lts-6C-Qxj" secondAttribute="top" constant="5" id="8i4-r4-nRN"/>
<constraint firstItem="b6A-lN-VQE" firstAttribute="leading" secondItem="Nqc-Bg-dn1" secondAttribute="leading" id="99D-aP-haF"/>
<constraint firstItem="Nqc-Bg-dn1" firstAttribute="leading" secondItem="iXm-m9-zoy" secondAttribute="trailing" constant="8" id="JH5-ma-c0h"/>
<constraint firstItem="Nqc-Bg-dn1" firstAttribute="top" secondItem="iXm-m9-zoy" secondAttribute="top" id="JLR-yN-gwj"/>
<constraint firstItem="9Oa-Mh-i12" firstAttribute="top" secondItem="Lts-6C-Qxj" secondAttribute="top" constant="6" id="VeB-NP-1wz"/>
<constraint firstItem="9Oa-Mh-i12" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="Nqc-Bg-dn1" secondAttribute="trailing" constant="10" id="eRm-o2-ve1"/>
<constraint firstItem="uez-Go-Gxg" firstAttribute="top" relation="greaterThanOrEqual" secondItem="Nqc-Bg-dn1" secondAttribute="bottom" constant="2" id="gRg-Ri-Uz1"/>
<constraint firstItem="b6A-lN-VQE" firstAttribute="top" secondItem="Nqc-Bg-dn1" secondAttribute="bottom" constant="2" id="gka-Ij-l3k"/>
<constraint firstItem="9Oa-Mh-i12" firstAttribute="bottom" secondItem="Nqc-Bg-dn1" secondAttribute="bottom" id="qDh-Tf-5he"/>
<constraint firstItem="uez-Go-Gxg" firstAttribute="leading" secondItem="b6A-lN-VQE" secondAttribute="trailing" constant="2" id="vEh-Cg-pK1"/>
<constraint firstAttribute="trailing" secondItem="uez-Go-Gxg" secondAttribute="trailing" constant="6" id="xlF-fd-cUd"/>
<constraint firstItem="iXm-m9-zoy" firstAttribute="leading" secondItem="Lts-6C-Qxj" secondAttribute="leading" constant="6" id="zrm-aD-pua"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="avatarView" destination="iXm-m9-zoy" id="lu4-LG-M5Z"/>
<outlet property="customView" destination="b6A-lN-VQE" id="P42-Kl-gqv"/>
<outlet property="nicknameView" destination="Nqc-Bg-dn1" id="aPw-Ny-09A"/>
<outlet property="stateView" destination="uez-Go-Gxg" id="3Id-bt-R9B"/>
<outlet property="timestampView" destination="9Oa-Mh-i12" id="n74-I5-X3p"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" reuseIdentifier="ChatTableViewAttachmentContinuationCell" rowHeight="71" id="Bmp-Dq-3Re" customClass="AttachmentChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="187" width="600" height="71"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="Bmp-Dq-3Re" id="GwA-3m-vJe">
<rect key="frame" x="0.0" y="0.0" width="600" height="71"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Label" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="tvq-lu-sxS">
<rect key="frame" x="580" y="55" width="14" height="12"/>
<constraints>
<constraint firstAttribute="width" constant="14" id="2Fs-fp-iUc"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="10"/>
<color key="textColor" name="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<view opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="kbZ-a4-GWn">
<rect key="frame" x="44" y="2" width="534" height="65"/>
</view>
</subviews>
<constraints>
<constraint firstItem="tvq-lu-sxS" firstAttribute="leading" secondItem="kbZ-a4-GWn" secondAttribute="trailing" constant="2" id="FLe-5F-Qax"/>
<constraint firstAttribute="bottom" secondItem="kbZ-a4-GWn" secondAttribute="bottom" constant="4" id="WNP-qK-f7g"/>
<constraint firstAttribute="trailing" secondItem="tvq-lu-sxS" secondAttribute="trailing" constant="6" id="aOb-H3-BnX"/>
<constraint firstItem="kbZ-a4-GWn" firstAttribute="top" secondItem="GwA-3m-vJe" secondAttribute="top" constant="2" id="mgF-rq-ZEh"/>
<constraint firstItem="kbZ-a4-GWn" firstAttribute="leading" secondItem="GwA-3m-vJe" secondAttribute="leading" constant="44" id="qad-d1-KYG"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="customView" destination="kbZ-a4-GWn" id="DGa-1v-cNX"/>
<outlet property="stateView" destination="tvq-lu-sxS" id="ZdY-CB-7vm"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" reuseIdentifier="ChatTableViewLinkPreviewCell" rowHeight="71" id="3YR-H5-lf7" customClass="LinkPreviewChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="258" width="600" height="71"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="3YR-H5-lf7" id="Aju-iM-m0V">
<rect key="frame" x="0.0" y="0.0" width="600" height="71"/>
<autoresizingMask key="autoresizingMask"/>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="ChatTableViewSystemCell" selectionStyle="default" indentationWidth="10" reuseIdentifier="ChatTableViewSystemCell" rowHeight="33" id="fRp-jo-yLX" customClass="ChatTableViewSystemCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="258" width="600" height="33"/>
<rect key="frame" x="0.0" y="329" width="600" height="33"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="fRp-jo-yLX" id="9NW-Te-8zl">
<rect key="frame" x="0.0" y="0.0" width="600" height="33"/>
@ -2488,6 +2432,26 @@
<outlet property="trustSwitch" destination="bsQ-Fj-zSv" id="9e2-f4-TMH"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="AttachmentsCell" textLabel="EpU-tc-DIx" rowHeight="44" style="IBUITableViewCellStyleDefault" id="09h-vH-qSu" customClass="CustomTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="472.5" width="414" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="09h-vH-qSu" id="Khc-ES-FDl">
<rect key="frame" x="0.0" y="0.0" width="383" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Attachments" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="EpU-tc-DIx">
<rect key="frame" x="20" y="0.0" width="355" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="N9z-ms-iaT" kind="show" identifier="chatShowAttachments" id="tSJ-Sm-lhD"/>
</connections>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="rSR-7g-CFO" id="lPG-hT-w4i"/>
@ -2712,6 +2676,55 @@
</objects>
<point key="canvasLocation" x="1444" y="2516.4917541229388"/>
</scene>
<!--Attachments-->
<scene sceneID="LjN-O9-imb">
<objects>
<collectionViewController storyboardIdentifier="ChatAttachmentsController" title="Attachments" id="N9z-ms-iaT" customClass="ChatAttachmentsController" customModule="Siskin" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" id="cHc-Jl-VKK">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="2" minimumInteritemSpacing="2" id="mzE-f3-Idj">
<size key="itemSize" width="100" height="100"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
</collectionViewFlowLayout>
<cells>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="AttachmentCellView" id="N8o-sn-l8d" customClass="ChatAttachmentsCellView" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="100" height="100"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<collectionViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="rlN-au-jgK">
<rect key="frame" x="0.0" y="0.0" width="100" height="100"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" verticalCompressionResistancePriority="250" image="defaultAvatar" translatesAutoresizingMaskIntoConstraints="NO" id="Rrz-MO-8C7">
<rect key="frame" x="0.0" y="0.0" width="100" height="100"/>
</imageView>
</subviews>
<constraints>
<constraint firstItem="Rrz-MO-8C7" firstAttribute="leading" secondItem="rlN-au-jgK" secondAttribute="leading" id="U81-x9-EBG"/>
<constraint firstItem="Rrz-MO-8C7" firstAttribute="top" secondItem="rlN-au-jgK" secondAttribute="top" id="hT3-1t-03m"/>
<constraint firstAttribute="bottom" secondItem="Rrz-MO-8C7" secondAttribute="bottom" id="ixY-C5-9Mh"/>
<constraint firstAttribute="trailing" secondItem="Rrz-MO-8C7" secondAttribute="trailing" id="x9A-iE-ugo"/>
</constraints>
</collectionViewCellContentView>
<connections>
<outlet property="imageField" destination="Rrz-MO-8C7" id="pNx-EL-tib"/>
</connections>
</collectionViewCell>
</cells>
<connections>
<outlet property="dataSource" destination="N9z-ms-iaT" id="NMe-lQ-1cq"/>
<outlet property="delegate" destination="N9z-ms-iaT" id="Bmm-gs-F7f"/>
</connections>
</collectionView>
<navigationItem key="navigationItem" title="Attachments" id="C1j-4i-HDP"/>
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="fYb-Gb-aLl" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1404.3478260869567" y="3669.6428571428569"/>
</scene>
<!--Navigation Controller-->
<scene sceneID="rMj-nV-pAP">
<objects>
@ -2734,12 +2747,16 @@
<resources>
<image name="appLogo" width="1024" height="1024"/>
<image name="attach" width="36" height="36"/>
<image name="defaultAvatar" width="192" height="192"/>
<image name="first" width="30" height="30"/>
<image name="messageArchiving" width="50" height="50"/>
<image name="send" width="36" height="36"/>
<namedColor name="secondaryLabelColor">
<color red="0.45100000500679016" green="0.45100000500679016" blue="0.47099998593330383" alpha="1" colorSpace="custom" customColorSpace="displayP3"/>
</namedColor>
<namedColor name="systemBackground">
<color red="0.98039215686274506" green="0.98039215686274506" blue="0.98039215686274506" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
<inferredMetricsTieBreakers>
<segue reference="UqQ-Sj-6Dq"/>

View file

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15510"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -17,164 +17,20 @@
<rect key="frame" x="0.0" y="0.0" width="375" height="623"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<tableView contentMode="scaleToFill" alwaysBounceVertical="YES" keyboardDismissMode="interactive" dataMode="prototypes" style="plain" separatorStyle="default" allowsSelection="NO" allowsSelectionDuringEditing="YES" allowsMultipleSelectionDuringEditing="YES" rowHeight="44" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="5el-fR-NA6">
<tableView contentMode="scaleToFill" alwaysBounceVertical="YES" keyboardDismissMode="interactive" dataMode="prototypes" style="plain" separatorStyle="default" allowsSelection="NO" allowsSelectionDuringEditing="YES" allowsMultipleSelectionDuringEditing="YES" rowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="5el-fR-NA6">
<rect key="frame" x="0.0" y="0.0" width="375" height="623"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<gestureRecognizers/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="ChatTableViewCellOutgoing" selectionStyle="default" indentationWidth="10" reuseIdentifier="MucChatTableViewCellOutgoing" rowHeight="42" id="hfn-DY-9WO" customClass="ChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="28" width="375" height="42"/>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="MucChatTableViewCell" selectionStyle="default" indentationWidth="10" reuseIdentifier="MucChatTableViewMessageCell" rowHeight="44" id="Bao-z7-vK8" customClass="ChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="28" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="hfn-DY-9WO" id="VSo-6t-dZh">
<rect key="frame" x="0.0" y="0.0" width="375" height="42"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Kka-jO-XHM">
<rect key="frame" x="314.5" y="2" width="43.5" height="21.5"/>
<subviews>
<label opaque="NO" contentMode="right" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lvk-dU-PmL">
<rect key="frame" x="4" y="4" width="35.5" height="13.5"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" contentMode="scaleAspectFill" horizontalHuggingPriority="249" verticalHuggingPriority="249" verticalCompressionResistancePriority="754" translatesAutoresizingMaskIntoConstraints="NO" id="WYw-Co-HWm">
<rect key="frame" x="0.0" y="21.5" width="43.5" height="0.0"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" image="YES" allowsDirectInteraction="YES"/>
<bool key="isElement" value="YES"/>
</accessibility>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="WYw-Co-HWm" secondAttribute="height" multiplier="3:2" id="QqH-ev-cVz"/>
</constraints>
</imageView>
</subviews>
<color key="backgroundColor" red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="WYw-Co-HWm" firstAttribute="leading" secondItem="Kka-jO-XHM" secondAttribute="leading" id="BeW-Yr-ppU"/>
<constraint firstItem="lvk-dU-PmL" firstAttribute="leading" secondItem="Kka-jO-XHM" secondAttribute="leading" constant="4" id="Ch8-Vw-NaL"/>
<constraint firstItem="WYw-Co-HWm" firstAttribute="width" secondItem="Kka-jO-XHM" secondAttribute="width" id="Dsh-CQ-Kh9"/>
<constraint firstAttribute="bottom" secondItem="WYw-Co-HWm" secondAttribute="bottom" id="E2x-pg-UDp"/>
<constraint firstItem="WYw-Co-HWm" firstAttribute="top" secondItem="lvk-dU-PmL" secondAttribute="bottom" constant="4" id="Yxb-9K-fql"/>
<constraint firstItem="lvk-dU-PmL" firstAttribute="top" secondItem="Kka-jO-XHM" secondAttribute="top" constant="4" id="bX4-Uz-AlK"/>
<constraint firstAttribute="trailing" secondItem="WYw-Co-HWm" secondAttribute="trailing" id="bhq-ap-GPG"/>
<constraint firstAttribute="trailing" secondItem="lvk-dU-PmL" secondAttribute="trailing" constant="4" id="zKQ-fx-0K7"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="Temporary label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="eu7-WK-b7v">
<rect key="frame" x="264" y="25.5" width="91" height="14.5"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<color key="textColor" red="0.66666666669999997" green="0.66666666669999997" blue="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstAttribute="trailingMargin" secondItem="Kka-jO-XHM" secondAttribute="trailing" constant="2" id="CKe-bZ-UGG"/>
<constraint firstItem="Kka-jO-XHM" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="VSo-6t-dZh" secondAttribute="leadingMargin" constant="45" id="dez-3X-Ryt"/>
<constraint firstAttribute="bottom" secondItem="eu7-WK-b7v" secondAttribute="bottom" constant="2" id="fZU-Yv-gYw"/>
<constraint firstItem="eu7-WK-b7v" firstAttribute="top" secondItem="Kka-jO-XHM" secondAttribute="bottom" constant="2" id="kAK-gm-GHH"/>
<constraint firstAttribute="trailingMargin" secondItem="eu7-WK-b7v" secondAttribute="trailing" constant="5" id="mgq-5R-86Y"/>
<constraint firstItem="Kka-jO-XHM" firstAttribute="top" secondItem="VSo-6t-dZh" secondAttribute="top" constant="2" id="n6f-pr-AmD"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="messageFrameView" destination="Kka-jO-XHM" id="JhX-bW-pfe"/>
<outlet property="messageTextView" destination="lvk-dU-PmL" id="tkV-6C-rQK"/>
<outlet property="previewView" destination="WYw-Co-HWm" id="bm0-4m-q62"/>
<outlet property="timestampView" destination="eu7-WK-b7v" id="Cqh-6d-oqn"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="MucChatTableViewCellIncoming" selectionStyle="default" indentationWidth="10" reuseIdentifier="MucChatTableViewCellIncoming" id="W4v-4P-YD6" customClass="ChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="70" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="W4v-4P-YD6" id="ZXT-32-hvW">
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="Bao-z7-vK8" id="FaA-wG-hX8">
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="Temporary label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="iCS-Bz-wPg">
<rect key="frame" x="41" y="27.5" width="52" height="14.5"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<color key="textColor" red="0.66666666669999997" green="0.66666666669999997" blue="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Sb3-cw-NYv">
<rect key="frame" x="36" y="17" width="129" height="8.5"/>
<subviews>
<label opaque="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Incoming message" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ouq-Ks-VIp">
<rect key="frame" x="4" y="4" width="121" height="0.5"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" contentMode="scaleAspectFill" horizontalHuggingPriority="249" verticalHuggingPriority="249" verticalCompressionResistancePriority="754" translatesAutoresizingMaskIntoConstraints="NO" id="I01-Rg-O9P">
<rect key="frame" x="0.0" y="8.5" width="129" height="0.0"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" image="YES" allowsDirectInteraction="YES"/>
<bool key="isElement" value="YES"/>
</accessibility>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="I01-Rg-O9P" secondAttribute="height" multiplier="3:2" id="fmP-0a-EwM"/>
</constraints>
</imageView>
</subviews>
<color key="backgroundColor" red="0.93725490199999995" green="0.93725490199999995" blue="0.95686274510000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="ouq-Ks-VIp" firstAttribute="leading" secondItem="Sb3-cw-NYv" secondAttribute="leading" constant="4" id="Bw6-Bo-mtB"/>
<constraint firstAttribute="bottom" secondItem="I01-Rg-O9P" secondAttribute="bottom" id="GkE-T1-J89"/>
<constraint firstItem="I01-Rg-O9P" firstAttribute="top" secondItem="ouq-Ks-VIp" secondAttribute="bottom" constant="4" id="V6p-dM-Asw"/>
<constraint firstItem="ouq-Ks-VIp" firstAttribute="top" secondItem="Sb3-cw-NYv" secondAttribute="top" constant="4" id="bKi-5L-dO7"/>
<constraint firstItem="I01-Rg-O9P" firstAttribute="leading" secondItem="Sb3-cw-NYv" secondAttribute="leading" id="jTb-Bo-ouH"/>
<constraint firstAttribute="trailing" secondItem="ouq-Ks-VIp" secondAttribute="trailing" constant="4" id="jn4-Us-sKa"/>
<constraint firstItem="I01-Rg-O9P" firstAttribute="width" secondItem="Sb3-cw-NYv" secondAttribute="width" id="ttE-GF-Mqj"/>
<constraint firstAttribute="trailing" secondItem="I01-Rg-O9P" secondAttribute="trailing" id="zOp-Se-wVT"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" verticalCompressionResistancePriority="751" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" highlighted="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="CML-cD-nxX" userLabel="Nickname">
<rect key="frame" x="20" y="2" width="28" height="13"/>
<fontDescription key="fontDescription" type="system" pointSize="11"/>
<color key="textColor" red="0.33333333329999998" green="0.33333333329999998" blue="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<imageView userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="nru-BN-5oN" customClass="AvatarView" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="2" y="17" width="30" height="30"/>
<constraints>
<constraint firstAttribute="width" constant="30" id="Aue-Ex-hYK"/>
<constraint firstAttribute="height" constant="30" id="Dhi-Kj-BFK"/>
</constraints>
</imageView>
</subviews>
<constraints>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="iCS-Bz-wPg" secondAttribute="trailing" constant="282" id="1WR-8M-PlW"/>
<constraint firstItem="iCS-Bz-wPg" firstAttribute="top" secondItem="Sb3-cw-NYv" secondAttribute="bottom" constant="2" id="84E-vj-ozO"/>
<constraint firstItem="CML-cD-nxX" firstAttribute="top" secondItem="ZXT-32-hvW" secondAttribute="top" constant="2" id="8P3-eq-Ozj"/>
<constraint firstItem="nru-BN-5oN" firstAttribute="top" secondItem="CML-cD-nxX" secondAttribute="bottom" constant="2" id="BeG-Et-Vvs"/>
<constraint firstAttribute="trailingMargin" relation="greaterThanOrEqual" secondItem="Sb3-cw-NYv" secondAttribute="trailing" constant="30" id="EFT-Wk-Km5"/>
<constraint firstItem="nru-BN-5oN" firstAttribute="leading" secondItem="ZXT-32-hvW" secondAttribute="leading" constant="2" id="FpX-9e-3VG"/>
<constraint firstItem="Sb3-cw-NYv" firstAttribute="top" secondItem="CML-cD-nxX" secondAttribute="bottom" constant="2" id="LnF-H2-0dG"/>
<constraint firstItem="iCS-Bz-wPg" firstAttribute="leading" secondItem="ZXT-32-hvW" secondAttribute="leading" constant="41" id="X4t-6L-cLd"/>
<constraint firstItem="Sb3-cw-NYv" firstAttribute="leading" secondItem="nru-BN-5oN" secondAttribute="trailing" constant="4" id="Xja-UD-G24"/>
<constraint firstItem="CML-cD-nxX" firstAttribute="leading" secondItem="ZXT-32-hvW" secondAttribute="leadingMargin" constant="5" id="bPt-82-1CC"/>
<constraint firstAttribute="bottom" secondItem="iCS-Bz-wPg" secondAttribute="bottom" constant="2" id="cWa-BH-kDw"/>
<constraint firstItem="iCS-Bz-wPg" firstAttribute="leading" secondItem="Sb3-cw-NYv" secondAttribute="leading" constant="5" id="jIP-Lh-mpi"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="avatarView" destination="nru-BN-5oN" id="2dR-dA-mb4"/>
<outlet property="messageFrameView" destination="Sb3-cw-NYv" id="k3R-lr-7rG"/>
<outlet property="messageTextView" destination="ouq-Ks-VIp" id="omm-G3-p7d"/>
<outlet property="nicknameView" destination="CML-cD-nxX" id="oMy-br-Kgm"/>
<outlet property="previewView" destination="I01-Rg-O9P" id="eKi-Cg-7Xm"/>
<outlet property="timestampView" destination="iCS-Bz-wPg" id="NfP-FF-Xyb"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="MucChatTableViewCell" selectionStyle="default" indentationWidth="10" reuseIdentifier="MucChatTableViewCell" rowHeight="106" id="Bao-z7-vK8" customClass="ChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="114" width="375" height="106"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="Bao-z7-vK8" id="FaA-wG-hX8">
<rect key="frame" x="0.0" y="0.0" width="375" height="106"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Temporary label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="tX3-Qg-Trf">
<rect key="frame" x="279.5" y="6" width="89.5" height="17"/>
<rect key="frame" x="279.5" y="6" width="89.5" height="15"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="12"/>
<color key="textColor" red="0.66666666669999997" green="0.66666666669999997" blue="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
@ -186,24 +42,15 @@
<constraint firstAttribute="height" constant="30" id="KtH-CC-WE1"/>
</constraints>
</imageView>
<label opaque="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Incoming message" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rfj-q1-YJd">
<rect key="frame" x="44" y="25" width="325" height="17"/>
<label contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Incoming message" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rfj-q1-YJd">
<rect key="frame" x="44" y="23" width="325" height="17"/>
<color key="backgroundColor" name="systemBackground"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" contentMode="scaleAspectFill" horizontalHuggingPriority="249" verticalHuggingPriority="249" verticalCompressionResistancePriority="754" translatesAutoresizingMaskIntoConstraints="NO" id="hhF-rV-ZOC">
<rect key="frame" x="44" y="42" width="325" height="60"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" image="YES" allowsDirectInteraction="YES"/>
<bool key="isElement" value="YES"/>
</accessibility>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="hhF-rV-ZOC" secondAttribute="height" multiplier="3:2" id="HU2-np-we2"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" highlighted="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="YMW-VZ-mYs" userLabel="Nickname">
<rect key="frame" x="44" y="5" width="38" height="18"/>
<rect key="frame" x="44" y="5" width="38" height="16"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="15"/>
<color key="textColor" cocoaTouchSystemColor="darkTextColor"/>
<nil key="highlightedColor"/>
@ -211,71 +58,54 @@
</subviews>
<constraints>
<constraint firstItem="rfj-q1-YJd" firstAttribute="leading" secondItem="YMW-VZ-mYs" secondAttribute="leading" id="3vj-UW-OH0"/>
<constraint firstItem="hhF-rV-ZOC" firstAttribute="trailing" secondItem="rfj-q1-YJd" secondAttribute="trailing" id="7rl-y3-hks"/>
<constraint firstItem="YMW-VZ-mYs" firstAttribute="leading" secondItem="lsv-vn-uow" secondAttribute="trailing" constant="8" id="B8Y-9k-Bch"/>
<constraint firstItem="hhF-rV-ZOC" firstAttribute="leading" secondItem="rfj-q1-YJd" secondAttribute="leading" id="Chy-7k-zxq"/>
<constraint firstItem="tX3-Qg-Trf" firstAttribute="top" secondItem="FaA-wG-hX8" secondAttribute="top" constant="6" id="GgD-fE-wn4"/>
<constraint firstItem="lsv-vn-uow" firstAttribute="leading" secondItem="FaA-wG-hX8" secondAttribute="leading" constant="6" id="Gj1-dl-ZmK"/>
<constraint firstItem="rfj-q1-YJd" firstAttribute="trailing" secondItem="tX3-Qg-Trf" secondAttribute="trailing" id="Gwl-2a-LuM"/>
<constraint firstAttribute="trailing" secondItem="tX3-Qg-Trf" secondAttribute="trailing" constant="6" id="LJb-6w-qRi"/>
<constraint firstItem="hhF-rV-ZOC" firstAttribute="top" secondItem="rfj-q1-YJd" secondAttribute="bottom" id="bKd-jc-wG8"/>
<constraint firstItem="tX3-Qg-Trf" firstAttribute="bottom" secondItem="YMW-VZ-mYs" secondAttribute="bottom" id="cWJ-4y-avo"/>
<constraint firstItem="YMW-VZ-mYs" firstAttribute="top" secondItem="lsv-vn-uow" secondAttribute="top" id="fiK-5j-FMH"/>
<constraint firstItem="rfj-q1-YJd" firstAttribute="top" secondItem="YMW-VZ-mYs" secondAttribute="bottom" constant="2" id="qRR-Mg-4sD"/>
<constraint firstAttribute="bottom" secondItem="hhF-rV-ZOC" secondAttribute="bottom" constant="4" id="qtB-d2-YRb"/>
<constraint firstItem="tX3-Qg-Trf" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="YMW-VZ-mYs" secondAttribute="trailing" constant="10" id="uzn-XZ-lAs"/>
<constraint firstItem="lsv-vn-uow" firstAttribute="top" secondItem="FaA-wG-hX8" secondAttribute="top" constant="5" id="waw-TD-b4j"/>
<constraint firstAttribute="bottom" secondItem="rfj-q1-YJd" secondAttribute="bottom" constant="4" id="yq4-ky-nVW"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="avatarView" destination="lsv-vn-uow" id="EMf-xg-3on"/>
<outlet property="messageTextView" destination="rfj-q1-YJd" id="IJP-SR-LHq"/>
<outlet property="nicknameView" destination="YMW-VZ-mYs" id="rZH-gq-k4Q"/>
<outlet property="previewView" destination="hhF-rV-ZOC" id="aFZ-DB-CYf"/>
<outlet property="timestampView" destination="tX3-Qg-Trf" id="dd8-gu-hig"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="MucChatTableViewCellContinuation" selectionStyle="default" indentationWidth="10" reuseIdentifier="MucChatTableViewCellContinuation" rowHeight="106" id="dYP-lW-wpx" customClass="ChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="220" width="375" height="106"/>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="MucChatTableViewCellContinuation" selectionStyle="default" indentationWidth="10" reuseIdentifier="MucChatTableViewMessageContinuationCell" rowHeight="44" id="dYP-lW-wpx" customClass="ChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="72" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="dYP-lW-wpx" id="fko-5f-K5r">
<rect key="frame" x="0.0" y="0.0" width="375" height="106"/>
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Incoming message" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="dEP-he-X8w">
<rect key="frame" x="44" y="2" width="325" height="17"/>
<label contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Incoming message" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="dEP-he-X8w">
<rect key="frame" x="44" y="2" width="325" height="38"/>
<color key="backgroundColor" name="systemBackground"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" contentMode="scaleAspectFill" horizontalHuggingPriority="249" verticalHuggingPriority="249" verticalCompressionResistancePriority="754" translatesAutoresizingMaskIntoConstraints="NO" id="RsO-ps-NkX">
<rect key="frame" x="44" y="19" width="325" height="83"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" image="YES" allowsDirectInteraction="YES"/>
<bool key="isElement" value="YES"/>
</accessibility>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="RsO-ps-NkX" secondAttribute="height" multiplier="3:2" id="byc-Ki-gdv"/>
</constraints>
</imageView>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="RsO-ps-NkX" secondAttribute="bottom" constant="4" id="ApI-ah-xHN"/>
<constraint firstItem="RsO-ps-NkX" firstAttribute="leading" secondItem="dEP-he-X8w" secondAttribute="leading" id="GKS-t3-996"/>
<constraint firstItem="dEP-he-X8w" firstAttribute="leading" secondItem="fko-5f-K5r" secondAttribute="leading" constant="44" id="I07-rN-ZmV"/>
<constraint firstItem="dEP-he-X8w" firstAttribute="top" secondItem="fko-5f-K5r" secondAttribute="top" constant="2" id="LwM-oa-9I1"/>
<constraint firstAttribute="trailing" secondItem="dEP-he-X8w" secondAttribute="trailing" constant="6" id="M7N-f1-L3t"/>
<constraint firstItem="RsO-ps-NkX" firstAttribute="top" secondItem="dEP-he-X8w" secondAttribute="bottom" id="fd7-O9-xfn"/>
<constraint firstItem="RsO-ps-NkX" firstAttribute="trailing" secondItem="dEP-he-X8w" secondAttribute="trailing" id="l3o-aw-ggf"/>
<constraint firstAttribute="bottom" secondItem="dEP-he-X8w" secondAttribute="bottom" constant="4" id="aBo-in-eY5"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="messageTextView" destination="dEP-he-X8w" id="17V-GJ-gQW"/>
<outlet property="previewView" destination="RsO-ps-NkX" id="Ne3-fm-hvn"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="MucChatTableViewSystemCell" selectionStyle="default" indentationWidth="10" reuseIdentifier="MucChatTableViewSystemCell" rowHeight="33" id="gr6-vB-GaQ" customClass="ChatTableViewSystemCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="326" width="375" height="33"/>
<rect key="frame" x="0.0" y="116" width="375" height="33"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="gr6-vB-GaQ" id="OfY-hw-4nf">
<rect key="frame" x="0.0" y="0.0" width="375" height="33"/>
@ -299,6 +129,111 @@
<outlet property="messageView" destination="Nuv-6c-iva" id="s97-uf-THh"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" reuseIdentifier="MucChatTableViewAttachmentContinuationCell" rowHeight="71" id="ahp-Qx-MWp" customClass="AttachmentChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="149" width="375" height="71"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="ahp-Qx-MWp" id="ac1-gM-yBJ">
<rect key="frame" x="0.0" y="0.0" width="375" height="71"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Label" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="P3i-AC-xFL">
<rect key="frame" x="355" y="55" width="14" height="12"/>
<constraints>
<constraint firstAttribute="width" constant="14" id="Y14-G0-nu5"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="10"/>
<color key="textColor" name="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<view opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="kTe-6g-st2">
<rect key="frame" x="44" y="2" width="309" height="65"/>
</view>
</subviews>
<constraints>
<constraint firstItem="P3i-AC-xFL" firstAttribute="leading" secondItem="kTe-6g-st2" secondAttribute="trailing" constant="2" id="Cr4-sc-TUi"/>
<constraint firstItem="kTe-6g-st2" firstAttribute="leading" secondItem="ac1-gM-yBJ" secondAttribute="leading" constant="44" id="VKP-mp-BKj"/>
<constraint firstAttribute="bottom" secondItem="kTe-6g-st2" secondAttribute="bottom" constant="4" id="lWa-cb-8fb"/>
<constraint firstItem="kTe-6g-st2" firstAttribute="top" secondItem="ac1-gM-yBJ" secondAttribute="top" constant="2" id="xjq-ms-Yzm"/>
<constraint firstAttribute="trailing" secondItem="P3i-AC-xFL" secondAttribute="trailing" constant="6" id="xmU-ks-Mes"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="customView" destination="kTe-6g-st2" id="qJW-9m-d9Q"/>
<outlet property="stateView" destination="P3i-AC-xFL" id="7uz-GN-cdl"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" reuseIdentifier="MucChatTableViewLinkPreviewCell" rowHeight="71" id="xgr-md-2Kg" customClass="LinkPreviewChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="220" width="375" height="71"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="xgr-md-2Kg" id="y2W-Ns-4xi">
<rect key="frame" x="0.0" y="0.0" width="375" height="71"/>
<autoresizingMask key="autoresizingMask"/>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" reuseIdentifier="MucChatTableViewAttachmentCell" rowHeight="71" id="hUG-WT-xM9" customClass="AttachmentChatTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="291" width="375" height="71"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="scaleToFill" tableViewCell="hUG-WT-xM9" id="jHQ-bY-717">
<rect key="frame" x="0.0" y="0.0" width="375" height="71"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="GPo-sU-r92" customClass="AvatarView" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="6" y="5" width="30" height="30"/>
<constraints>
<constraint firstAttribute="width" constant="30" id="7tH-nB-acb"/>
<constraint firstAttribute="height" constant="30" id="BHn-yR-3Q2"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="uvx-Hg-CEC">
<rect key="frame" x="44" y="5" width="38" height="18"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="15"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="600" text="Temporary label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Pks-KM-kQr">
<rect key="frame" x="279" y="6" width="90" height="17"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="12"/>
<color key="textColor" red="0.66666666669999997" green="0.66666666669999997" blue="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Label" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="oxt-Kq-u6J">
<rect key="frame" x="355" y="55" width="14" height="12"/>
<constraints>
<constraint firstAttribute="width" constant="14" id="kYu-n7-bfd"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="10"/>
<color key="textColor" name="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<view opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="KOz-ZW-EtH">
<rect key="frame" x="44" y="25" width="309" height="42"/>
</view>
</subviews>
<constraints>
<constraint firstItem="Pks-KM-kQr" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="uvx-Hg-CEC" secondAttribute="trailing" constant="10" id="4NB-YE-zBd"/>
<constraint firstItem="oxt-Kq-u6J" firstAttribute="leading" secondItem="KOz-ZW-EtH" secondAttribute="trailing" constant="2" id="6KF-mg-jgP"/>
<constraint firstAttribute="trailing" secondItem="Pks-KM-kQr" secondAttribute="trailing" constant="6" id="9Nn-fo-XE3"/>
<constraint firstItem="uvx-Hg-CEC" firstAttribute="leading" secondItem="GPo-sU-r92" secondAttribute="trailing" constant="8" id="CqX-6J-WJk"/>
<constraint firstAttribute="trailing" secondItem="oxt-Kq-u6J" secondAttribute="trailing" constant="6" id="Dlc-7d-qh5"/>
<constraint firstAttribute="bottom" secondItem="KOz-ZW-EtH" secondAttribute="bottom" constant="4" id="F4d-Bp-nov"/>
<constraint firstItem="KOz-ZW-EtH" firstAttribute="top" secondItem="uvx-Hg-CEC" secondAttribute="bottom" constant="2" id="Jxa-dM-39g"/>
<constraint firstItem="GPo-sU-r92" firstAttribute="leading" secondItem="jHQ-bY-717" secondAttribute="leading" constant="6" id="SDt-uy-3SS"/>
<constraint firstItem="Pks-KM-kQr" firstAttribute="top" secondItem="jHQ-bY-717" secondAttribute="top" constant="6" id="VGN-f5-Pvm"/>
<constraint firstItem="oxt-Kq-u6J" firstAttribute="top" relation="greaterThanOrEqual" secondItem="uvx-Hg-CEC" secondAttribute="bottom" constant="2" id="a86-y0-420"/>
<constraint firstItem="GPo-sU-r92" firstAttribute="top" secondItem="jHQ-bY-717" secondAttribute="top" constant="5" id="eJr-oL-kBd"/>
<constraint firstItem="Pks-KM-kQr" firstAttribute="bottom" secondItem="uvx-Hg-CEC" secondAttribute="bottom" id="ged-sv-Op8"/>
<constraint firstItem="uvx-Hg-CEC" firstAttribute="top" secondItem="GPo-sU-r92" secondAttribute="top" id="kUu-mp-gzq"/>
<constraint firstItem="KOz-ZW-EtH" firstAttribute="leading" secondItem="uvx-Hg-CEC" secondAttribute="leading" id="qLQ-to-YAY"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="avatarView" destination="GPo-sU-r92" id="kXQ-Pa-1rN"/>
<outlet property="customView" destination="KOz-ZW-EtH" id="GoL-hp-Al8"/>
<outlet property="nicknameView" destination="uvx-Hg-CEC" id="m3g-WQ-xJ7"/>
<outlet property="stateView" destination="oxt-Kq-u6J" id="vTW-DK-wco"/>
<outlet property="timestampView" destination="Pks-KM-kQr" id="7CX-qx-dTy"/>
</connections>
</tableViewCell>
</prototypes>
<connections>
<outletCollection property="gestureRecognizers" destination="Z94-Hf-jAY" appends="YES" id="0jw-xb-dCn"/>
@ -698,6 +633,30 @@
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle=" " id="2ZD-5S-4hK">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="wBw-1J-Xau" style="IBUITableViewCellStyleDefault" id="3IN-v0-ayC" customClass="CustomTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="429.5" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="3IN-v0-ayC" id="iXe-AG-FnD">
<rect key="frame" x="0.0" y="0.0" width="348" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Attachments" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="wBw-1J-Xau">
<rect key="frame" x="16" y="0.0" width="324" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<connections>
<segue destination="WK7-rl-udc" kind="show" identifier="chatShowAttachments" id="zCm-lW-ix4"/>
</connections>
</tableViewCell>
</cells>
</tableViewSection>
</sections>
<connections>
<outlet property="dataSource" destination="IGA-uE-mHb" id="iEv-vE-Trn"/>
@ -716,6 +675,16 @@
</objects>
<point key="canvasLocation" x="3529" y="649"/>
</scene>
<!--ChatAttachmentsController-->
<scene sceneID="xFY-s8-0Ui">
<objects>
<viewControllerPlaceholder storyboardName="Main" referencedIdentifier="ChatAttachmentsController" id="WK7-rl-udc" sceneMemberID="viewController">
<navigationItem key="navigationItem" id="JXN-3A-Aud"/>
</viewControllerPlaceholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="TmV-Yt-wrX" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="4301" y="778"/>
</scene>
<!--Navigation Controller-->
<scene sceneID="hfa-O8-oHo">
<objects>
@ -1160,5 +1129,8 @@
<namedColor name="secondaryLabelColor">
<color red="0.45100000500679016" green="0.45100000500679016" blue="0.47099998593330383" alpha="1" colorSpace="custom" customColorSpace="displayP3"/>
</namedColor>
<namedColor name="systemBackground">
<color red="0.98039215686274506" green="0.98039215686274506" blue="0.98039215686274506" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View file

@ -84,6 +84,8 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Used to save attachements from conversations</string>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
@ -91,5 +93,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UTExportedTypeDeclarations</key>
<array/>
</dict>
</plist>

View file

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14868" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="kwm-Ck-fLB">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14824"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>

View file

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15510"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
@ -290,15 +290,15 @@
<segue destination="FGQ-GL-dYt" kind="showDetail" customClass="NavigationControllerWrappingSegue" customModule="Siskin" id="gsA-2D-Fg6"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="aboutSettingsViewCall" selectionStyle="default" indentationWidth="10" reuseIdentifier="AboutSettingsViewCell" id="00j-hl-4km" userLabel="AboutSettingsViewCall" customClass="CustomTableViewCell" customModule="Siskin" customModuleProvider="target">
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="aboutSettingsViewCall" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="AboutSettingsViewCell" id="00j-hl-4km" userLabel="AboutSettingsViewCall" customClass="CustomTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="407.5" width="414" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="00j-hl-4km" id="BKo-8a-1ti">
<rect key="frame" x="0.0" y="0.0" width="414" height="44"/>
<rect key="frame" x="0.0" y="0.0" width="383" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="About" textAlignment="natural" lineBreakMode="wordWrap" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="3vM-ad-4ap">
<rect key="frame" x="20" y="11" width="374" height="22"/>
<rect key="frame" x="20" y="11" width="355" height="22"/>
<accessibility key="accessibilityConfiguration" identifier="accountName"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
@ -313,6 +313,9 @@
</constraints>
</tableViewCellContentView>
<accessibility key="accessibilityConfiguration" identifier="aboutSettingsViewCall"/>
<connections>
<segue destination="5B4-ae-wmU" kind="show" id="JiS-ax-O98"/>
</connections>
</tableViewCell>
</prototypes>
<connections>
@ -574,15 +577,15 @@
<outlet property="switchView" destination="BwA-0i-nsF" id="p3D-DW-hiS"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="simplifiedLinkToFileTableViewCell" selectionStyle="default" indentationWidth="10" reuseIdentifier="SimplifiedLinkToFileTableViewCell" id="CHa-Jc-woM" userLabel="SimplifiedLinkToFileTableViewCell" customClass="SwitchTableViewCell" customModule="Siskin" customModuleProvider="target">
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="simplifiedLinkToFileTableViewCell" selectionStyle="default" indentationWidth="10" reuseIdentifier="LinkPreviewsTableViewCell" id="CHa-Jc-woM" userLabel="SimplifiedLinkToFileTableViewCell" customClass="SwitchTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="363.5" width="414" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="CHa-Jc-woM" id="hJd-r7-bvW">
<rect key="frame" x="0.0" y="0.0" width="414" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Simplified link to HTTP file" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ybc-mt-hVG">
<rect key="frame" x="20" y="12" width="200.5" height="20.5"/>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Show link previews" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ybc-mt-hVG">
<rect key="frame" x="20" y="11.5" width="145.5" height="21"/>
<accessibility key="accessibilityConfiguration" identifier="accountName"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
@ -649,8 +652,8 @@
<rect key="frame" x="0.0" y="0.0" width="414" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Max image preview size" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="hYx-To-bTs">
<rect key="frame" x="20" y="12" width="182" height="20.5"/>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Auto file download limit" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="hYx-To-bTs">
<rect key="frame" x="20" y="12" width="177.5" height="20.5"/>
<accessibility key="accessibilityConfiguration" identifier="accountName"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
@ -676,14 +679,14 @@
</tableViewCellContentView>
<accessibility key="accessibilityConfiguration" identifier="maxImagePreviewSizeTableViewCell"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="clearImagePreviewTableViewCell" selectionStyle="default" indentationWidth="10" reuseIdentifier="ClearImagePreviewTableViewCell" id="BNU-sl-iSH" userLabel="ClearImagePreviewTableViewCell" customClass="CustomTableViewCell" customModule="Siskin" customModuleProvider="target">
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="clearImagePreviewTableViewCell" selectionStyle="default" indentationWidth="10" reuseIdentifier="ClearDownloadStoreTableViewCell" id="BNU-sl-iSH" userLabel="ClearImagePreviewTableViewCell" customClass="CustomTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="495.5" width="414" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="BNU-sl-iSH" id="Rhf-1K-n9A">
<rect key="frame" x="0.0" y="0.0" width="414" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Clear cache" textAlignment="natural" lineBreakMode="wordWrap" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="sCy-8x-KOF">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Clear download cache" textAlignment="natural" lineBreakMode="wordWrap" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="sCy-8x-KOF">
<rect key="frame" x="20" y="11" width="374" height="22"/>
<accessibility key="accessibilityConfiguration" identifier="accountName"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
@ -1150,41 +1153,8 @@
<outlet property="switchView" destination="PRY-Rw-kCQ" id="amr-y6-yCU"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="EnableNewUITableViewCell" selectionStyle="default" indentationWidth="10" reuseIdentifier="EnableNewUITableViewCell" id="wqW-gF-BK8" userLabel="EnableNewUITableViewCell" customClass="SwitchTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="143.5" width="414" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="wqW-gF-BK8" id="M3O-dZ-rUE">
<rect key="frame" x="0.0" y="0.0" width="414" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="New UI" textAlignment="natural" lineBreakMode="wordWrap" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="n3F-rS-8OD">
<rect key="frame" x="20" y="11.5" width="55.5" height="21"/>
<accessibility key="accessibilityConfiguration" identifier="accountName"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" contentHorizontalAlignment="center" contentVerticalAlignment="center" on="YES" translatesAutoresizingMaskIntoConstraints="NO" id="xts-GH-wU0">
<rect key="frame" x="345" y="6.5" width="51" height="31"/>
<connections>
<action selector="valueChanged:" destination="wqW-gF-BK8" eventType="valueChanged" id="hXy-MR-STS"/>
</connections>
</switch>
</subviews>
<constraints>
<constraint firstItem="xts-GH-wU0" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="n3F-rS-8OD" secondAttribute="trailing" constant="8" id="28z-Gj-wRv"/>
<constraint firstItem="xts-GH-wU0" firstAttribute="centerY" secondItem="M3O-dZ-rUE" secondAttribute="centerY" id="93A-SF-YTj"/>
<constraint firstItem="n3F-rS-8OD" firstAttribute="centerY" secondItem="M3O-dZ-rUE" secondAttribute="centerY" id="XSg-3v-OkN"/>
<constraint firstAttribute="trailingMargin" secondItem="xts-GH-wU0" secondAttribute="trailing" id="hug-nf-sqx"/>
<constraint firstAttribute="leadingMargin" secondItem="n3F-rS-8OD" secondAttribute="leading" id="zCO-rb-jKX"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="switchView" destination="xts-GH-wU0" id="gvk-zA-qhx"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="EnableMarkdownTableViewCell" selectionStyle="default" indentationWidth="10" reuseIdentifier="EnableMarkdownTableViewCell" id="GN8-OH-Bie" userLabel="EnableMarkdownTableViewCell" customClass="SwitchTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="187.5" width="414" height="44"/>
<rect key="frame" x="0.0" y="143.5" width="414" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="GN8-OH-Bie" id="vwH-M8-Zdt">
<rect key="frame" x="0.0" y="0.0" width="414" height="44"/>
@ -1217,7 +1187,7 @@
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" restorationIdentifier="EnableEmoticonsTableViewCell" selectionStyle="default" indentationWidth="10" reuseIdentifier="EnableEmoticonsTableViewCell" id="hfv-4K-f0U" userLabel="EnableEmoticonsTableViewCell" customClass="SwitchTableViewCell" customModule="Siskin" customModuleProvider="target">
<rect key="frame" x="0.0" y="231.5" width="414" height="44"/>
<rect key="frame" x="0.0" y="187.5" width="414" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="hfv-4K-f0U" id="Zpe-wG-sQi">
<rect key="frame" x="0.0" y="0.0" width="414" height="44"/>
@ -1260,5 +1230,15 @@
</objects>
<point key="canvasLocation" x="1839" y="1742"/>
</scene>
<!--AboutController-->
<scene sceneID="5Mf-Za-3AY">
<objects>
<viewControllerPlaceholder storyboardName="Info" referencedIdentifier="AboutController" id="5B4-ae-wmU" sceneMemberID="viewController">
<navigationItem key="navigationItem" id="XxE-3L-zjI"/>
</viewControllerPlaceholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="f6S-Ox-AWU" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2050" y="2202"/>
</scene>
</scenes>
</document>

View file

@ -0,0 +1,598 @@
//
// AttachmentChatTableViewCell.swift
//
// Siskin IM
// 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. Look for COPYING file in the top folder.
// If not, see https://www.gnu.org/licenses/.
//
import UIKit
import MobileCoreServices
import LinkPresentation
import TigaseSwift
class AttachmentChatTableViewCell: BaseChatTableViewCell, UIContextMenuInteractionDelegate {
@IBOutlet var customView: UIView!;
fileprivate var tapGestureRecognizer: UITapGestureRecognizer?;
fileprivate var longPressGestureRecognizer: UILongPressGestureRecognizer?;
private var item: ChatAttachment?;
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!);
if #available(iOS 13.0, *) {
customView.addInteraction(UIContextMenuInteraction(delegate: self));
customView.overrideUserInterfaceStyle = Appearance.current!.isDark ? .dark : .light;
} else {
longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPressGestureDidFire));
longPressGestureRecognizer?.cancelsTouchesInView = true;
longPressGestureRecognizer?.delegate = self;
customView.addGestureRecognizer(longPressGestureRecognizer!);
}
}
func set(attachment item: ChatAttachment) {
self.item = item;
super.set(item: item);
if let localUrl = DownloadStore.instance.url(for: "\(item.id)") {
documentController = UIDocumentInteractionController(url: localUrl);
if #available(iOS 13.0, *) {
//self.longPressGestureRecognizer?.isEnabled = false;
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.overrideUserInterfaceStyle = Appearance.current.isDark ? .dark :.light;
linkView.setContentHuggingPriority(.defaultHigh, for: .vertical);
linkView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical);
linkView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal);
linkView.translatesAutoresizingMaskIntoConstraints = 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: { meta1 in
guard meta1 != nil else {
return;
}
DBChatHistoryStore.instance.updateItem(for: item.account, with: item.jid, id: item.id, updateAppendix: { appendix in
});
})
}
} else {
let attachmentInfo = (self.linkView as? AttachmentInfoView) ?? AttachmentInfoView(frame: .zero);
attachmentInfo.backgroundColor = self.backgroundColor;
attachmentInfo.isOpaque = true;
self.linkView = attachmentInfo;
//attachmentInfo.cellView = self;
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);
}
} 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);
switch item.appendix.state {
case .new:
let sizeLimit = Settings.fileDownloadSizeLimit.integer();
if sizeLimit > 0 {
if let sessionObject = XmppService.instance.getClient(for: item.account)?.sessionObject, (RosterModule.getRosterStore(sessionObject).get(for: JID(item.jid))?.subscription ?? .none).isFrom || (DBChatStore.instance.getChat(for: item.account, with: item.jid) as? Room != nil) {
DownloadManager.instance.download(item: item, maxSize: 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));
}
}
}
@available(iOS 13.0, *)
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
var cfg = UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { suggestedActions -> UIMenu? in
return self.prepareContextMenu();
};
return cfg;
}
@available(iOS 13.0, *)
func prepareContextMenu() -> UIMenu {
guard let item = self.item else {
return UIMenu(title: "");
}
if let localUrl = DownloadStore.instance.url(for: "\(item.id)") {
let items = [
UIAction(title: "Preview", image: UIImage(systemName: "eye.fill"), handler: { action in
print("preview called");
self.open(url: localUrl, preview: true);
}),
UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc"), handler: { action in
guard let text = self.item?.copyText(withTimestamp: Settings.CopyMessagesWithTimestamps.getBool(), withSender: false) else {
return;
}
UIPasteboard.general.strings = [text];
UIPasteboard.general.string = text;
}),
UIAction(title: "Share..", image: UIImage(systemName: "square.and.arrow.up"), handler: { action in
print("share called");
self.open(url: localUrl, preview: false);
}),
UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: [.destructive], handler: { action in
print("delete called");
DownloadStore.instance.deleteFile(for: "\(item.id)");
DBChatHistoryStore.instance.updateItem(for: item.account, with: item.jid, id: item.id, updateAppendix: { appendix in
appendix.state = .removed;
})
}),
UIAction(title: "More..", 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: "Copy", image: UIImage(systemName: "doc.on.doc"), handler: { action in
guard let text = self.item?.copyText(withTimestamp: Settings.CopyMessagesWithTimestamps.getBool(), withSender: false) else {
return;
}
UIPasteboard.general.strings = [text];
UIPasteboard.general.string = text;
}),
UIAction(title: "Download", image: UIImage(systemName: "square.and.arrow.down"), handler: { action in
print("download called");
self.download(for: item);
}),
UIAction(title: "More..", 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);
}
}
@objc func longPressGestureDidFire(_ recognizer: UILongPressGestureRecognizer) {
guard recognizer.state == .recognized else {
return;
}
downloadOrOpen();
}
@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)
}
}
longPressGestureRecognizer?.isEnabled = documentController == nil;
}
}
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) {
print("opening a file:", url, "exists:", FileManager.default.fileExists(atPath: url.path));// "tmp:", tmpUrl);
let documentController = UIDocumentInteractionController(url: url);
documentController.delegate = self;
print("detected uti:", documentController.uti, "for:", documentController.url);
if preview && documentController.presentPreview(animated: true) {
self.documentController = documentController;
} else if documentController.presentOptionsMenu(from: CGRect.zero, in: self.customView, animated: true) {
self.documentController = documentController;
}
}
func download(for item: ChatAttachment) {
DownloadManager.instance.download(item: item, maxSize: Int64.max);
(self.linkView as? AttachmentInfoView)?.progress(show: true);
}
// func documentInteractionControllerViewForPreview(_ controller: UIDocumentInteractionController) -> UIView? {
// return self;
// }
// func documentInteractionControllerDidDismissOptionsMenu(_ controller: UIDocumentInteractionController) {
// print("file sharing cancelled!");
// self.documentController = nil;
// }
//
// func documentInteractionController(_ controller: UIDocumentInteractionController, didEndSendingToApplication application: String?) {
// print("file shared with:", application);
// self.documentController = nil;
// }
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: "Download", message: "File is not available locally. Should it be downloaded?", preferredStyle: .alert);
alert.addAction(UIAlertAction(title: "Yes", style: .default, handler: { (action) in
self.download(for: item);
}))
alert.addAction(UIAlertAction(title: "No", style: .cancel, handler: nil));
if let controller = (UIApplication.shared.delegate as? AppDelegate)?.window?.rootViewController {
controller.present(alert, animated: true, completion: nil);
}
}
}
class AttachmentInfoView: UIView {
let iconView: ImageAttachmentPreview;
let filename: UILabel;
let details: UILabel;
private var viewType: ViewType = .none {
didSet {
guard viewType != oldValue else {
return;
}
switch oldValue {
case .none:
break;
case .file:
NSLayoutConstraint.deactivate(fileViewConstraints);
case .imagePreview:
NSLayoutConstraint.deactivate(imagePreviewConstraints);
}
switch viewType {
case .none:
break;
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] = [];
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.font = UIFont.systemFont(ofSize: UIFont.systemFontSize - 1, weight: .semibold);
filename.textColor = Appearance.current.labelColor;
filename.translatesAutoresizingMaskIntoConstraints = false;
filename.setContentHuggingPriority(.defaultHigh, for: .horizontal);
filename.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal);
details = UILabel(frame: .zero);
details.font = UIFont.systemFont(ofSize: UIFont.systemFontSize - 2, weight: .regular);
details.textColor = Appearance.current.secondaryLabelColor;
details.translatesAutoresizingMaskIntoConstraints = false;
details.setContentHuggingPriority(.defaultHigh, for: .horizontal)
details.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal);
super.init(frame: frame);
self.clipsToBounds = true
self.translatesAutoresizingMaskIntoConstraints = false;
self.isOpaque = false;
addSubview(iconView);
addSubview(filename);
addSubview(details);
fileViewConstraints = [
iconView.heightAnchor.constraint(equalToConstant: 30),
iconView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12),
iconView.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
iconView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -8),
filename.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 12),
filename.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
filename.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor, constant: -12),
details.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 12),
details.topAnchor.constraint(equalTo: filename.bottomAnchor, constant: 0),
details.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -8),
// -- this is causing issue with progress indicatior!!
details.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor, constant: -12),
details.heightAnchor.constraint(equalTo: filename.heightAnchor)
];
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: 12),
filename.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 4),
filename.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor, constant: -12),
details.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12),
details.topAnchor.constraint(equalTo: filename.bottomAnchor, constant: 0),
details.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -8),
details.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor, constant: -12),
details.heightAnchor.constraint(equalTo: filename.heightAnchor)
];
}
required init?(coder: NSCoder) {
return nil;
}
override func draw(_ rect: CGRect) {
let path = UIBezierPath(roundedRect: rect, cornerRadius: 10);
path.addClip();
Appearance.current.secondarySystemBackground.setFill();
path.fill();
super.draw(rect);
}
func set(item: ChatAttachment) {
if let fileUrl = DownloadStore.instance.url(for: "\(item.id)") {
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;
print("preview of:" , fileUrl, fileUrl.path);
iconView.image = UIImage(contentsOfFile: fileUrl.path)!;
} else {
self.viewType = .file;
iconView.image = UIImage.icon(forFile: fileUrl, mimeType: item.appendix.mimetype);
}
} else {
details.text = fileSize;
iconView.image = UIImage.icon(forFile: fileUrl, mimeType: item.appendix.mimetype);
self.viewType = .file;
}
} else {
let filename = item.appendix.filename ?? URL(string: item.url)?.lastPathComponent ?? "";
if filename.isEmpty {
self.filename.text = "Unknown file";
} else {
self.filename.text = filename;
}
if let size = item.appendix.filesize {
if let mimetype = item.appendix.mimetype, let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimetype as CFString, nil)?.takeRetainedValue(), let typeName = UTTypeCopyDescription(uti)?.takeRetainedValue() as String? {
let fileSize = fileSizeToString(UInt64(size));
details.text = "\(typeName) - \(fileSize)";
iconView.image = UIImage.icon(forUTI: uti as String);
} else {
details.text = 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: .gray);
view.translatesAutoresizingMaskIntoConstraints = false;
view.color = Appearance.current.tintColor;
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
}
}
}
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;
print("got:", icons.last, "for:", url.absoluteString);
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;
print("got:", icons.last, "for:", utiString);
return icons.last;
}
}

View file

@ -0,0 +1,159 @@
//
// BaseChatTableViewCell.swift
//
// Siskin IM
// 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. Look for COPYING file in the top folder.
// If not, see https://www.gnu.org/licenses/.
//
import UIKit
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, jj: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, jj:mm", 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?;
func formatTimestamp(_ ts: Date) -> String {
let flags: Set<Calendar.Component> = [.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);
}
}
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
if avatarView != nil {
avatarView!.layer.masksToBounds = true;
avatarView!.layer.cornerRadius = avatarView!.frame.height / 2;
}
}
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");
}
print("offset", offset, "view", view);
view.backgroundColor = colors[offset];
}
} else {
super.setSelected(selected, animated: animated);
selectedBackgroundView = nil;
}
// Configure the view for the selected state
}
func set(item: ChatViewItemProtocol) {
var timestamp = formatTimestamp(item.timestamp);
switch item.encryption {
case .decrypted, .notForThisDevice, .decryptionFailed:
timestamp = "\u{1F512} \(timestamp)";
default:
break;
}
switch item.state {
case .incoming_error, .incoming_error_unread:
//elf.message.textColor = NSColor.systemRed;
self.stateView?.text = "\u{203c}";
case .outgoing_unsent:
//self.message.textColor = NSColor.secondaryLabelColor;
self.stateView?.text = "\u{1f4e4}";
case .outgoing_delivered:
//self.message.textColor = nil;
self.stateView?.text = "\u{2713}";
case .outgoing_error, .outgoing_error_unread:
//self.message.textColor = nil;
self.stateView?.text = "\u{203c}";
default:
//self.state?.stringValue = "";
self.stateView?.text = nil;//NSColor.textColor;
}
if stateView == nil {
if item.state.direction == .outgoing {
timestampView?.textColor = UIColor.lightGray;
if item.state.isError {
timestampView?.textColor = UIColor.red;
timestamp = "\(timestamp) Not delivered\u{203c}";
} else if item.state == .outgoing_delivered {
timestamp = "\(timestamp) \u{2713}";
}
}
}
timestampView?.text = timestamp;
self.nicknameView?.textColor = Appearance.current.labelColor;
if item.state.isError {
if item.state.direction == .outgoing {
self.accessoryType = .detailButton;
self.tintColor = UIColor.red;
}
} else {
self.accessoryType = .none;
self.tintColor = stateView?.tintColor;
}
self.stateView?.textColor = item.state.isError && item.state.direction == .incoming ? UIColor.red : Appearance.current.labelColor;
self.timestampView?.textColor = item.state.isError && item.state.direction == .incoming ? UIColor.red : Appearance.current.labelColor;
}
@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(_:));
}
}

View file

@ -140,7 +140,7 @@ class BaseChatViewController: UIViewController, UITextViewDelegate, UITableViewD
}
override func viewDidDisappear(_ animated: Bool) {
NotificationCenter.default.removeObserver(self);
//NotificationCenter.default.removeObserver(self);
super.viewDidDisappear(animated);
}

View file

@ -27,6 +27,8 @@ class BaseChatViewControllerWithDataSource: BaseChatViewController, ChatViewData
let firstRowIndexPath = IndexPath(row: 0, section: 0);
private var loaded: Bool = false;
override func viewDidLoad() {
super.viewDidLoad();
dataSource.delegate = self;
@ -36,10 +38,13 @@ class BaseChatViewControllerWithDataSource: BaseChatViewController, ChatViewData
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated);
self.dataSource.refreshData(unread: chat.unread) { (firstUnread) in
print("got first unread at:", firstUnread);
if self.tableView.numberOfRows(inSection: 0) > 0 {
self.tableView.scrollToRow(at: IndexPath(row: firstUnread ?? 0, section: 0), at: .none, animated: true);
if !loaded {
loaded = true;
self.dataSource.refreshData(unread: chat.unread) { (firstUnread) in
print("got first unread at:", firstUnread);
if self.tableView.numberOfRows(inSection: 0) > 0 {
self.tableView.scrollToRow(at: IndexPath(row: firstUnread ?? 0, section: 0), at: .none, animated: true);
}
}
}
}

View file

@ -40,11 +40,15 @@ class BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar: BaseChatView
override func viewWillAppear(_ animated: Bool) {
bottomViewHeightConstraint?.isActive = false;
var items: [UIMenuItem] = UIMenuController.shared.menuItems ?? [];
items.append(UIMenuItem(title: "More..", action: #selector(ChatTableViewCell.actionMore(_:))));
UIMenuController.shared.menuItems = items;
if #available(iOS 13.0, *) {
} else {
var items: [UIMenuItem] = UIMenuController.shared.menuItems ?? [];
items.append(UIMenuItem(title: "More..", action: #selector(ChatTableViewCell.actionMore(_:))));
UIMenuController.shared.menuItems = items;
customToolbar?.barStyle = Appearance.current.isDark ? .black : .default;
customToolbar?.barStyle = Appearance.current.isDark ? .black : .default;
}
super.viewWillAppear(animated);
NotificationCenter.default.addObserver(self, selector: #selector(showEditToolbar), name: NSNotification.Name("tableViewCellShowEditToolbar"), object: nil);
@ -71,6 +75,8 @@ class BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar: BaseChatView
let timestampsSwitch = UIBarButtonItem(title: "Timestamps: \(self.withTimestamps ? "ON" : "OFF")", style: .plain, target: self, action: #selector(BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar.switchWithTimestamps));
self.timestampsSwitch = timestampsSwitch;
self.updateTimestampSwitch();
let items = [
timestampsSwitch,
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
@ -113,7 +119,18 @@ class BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar: BaseChatView
@objc func switchWithTimestamps() {
withTimestamps = !withTimestamps;
timestampsSwitch?.title = "Timestamps: \(withTimestamps ? "ON" : "OFF")";
updateTimestampSwitch();
}
private func updateTimestampSwitch() {
if #available(iOS 13.0, *) {
timestampsSwitch?.image = UIImage(systemName: withTimestamps ? "clock.fill" : "clock");
timestampsSwitch?.title = nil;
} else {
timestampsSwitch?.title = "Timestamps: \(withTimestamps ? "ON" : "OFF")";
timestampsSwitch?.image = nil;
}
}
fileprivate func copyMessageInt(paths: [IndexPath]) {
@ -132,17 +149,24 @@ class BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar: BaseChatView
}
func tableView(_ tableView: UITableView, canPerformAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) -> Bool {
if #available(iOS 13.0, *) {
} else {
if action == #selector(UIResponderStandardEditActions.copy(_:)) {
return true;
}
if customToolbar != nil && action == #selector(ChatTableViewCell.actionMore(_:)) {
return true;
}
}
return false;
}
func tableView(_ tableView: UITableView, shouldShowMenuForRowAt indexPath: IndexPath) -> Bool {
return true;
if #available(iOS 13.0, *) {
return false;
} else {
return true;
}
}
func tableView(_ tableView: UITableView, performAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) {
@ -153,46 +177,22 @@ class BaseChatViewControllerWithDataSourceAndContextMenuAndToolbar: BaseChatView
}
func getTextOfSelectedRows(paths: [IndexPath], withTimestamps: Bool, handler: (([String]) -> Void)?) {
let items: [ChatMessage] = paths.map({ index in dataSource.getItem(at: index.row) }).map({ it -> ChatMessage? in
return it as? ChatMessage;
}).filter({ it -> Bool in it != nil }).map({ it -> ChatMessage in return it! }).sorted { (it1, it2) -> Bool in
let items: [ChatViewItemProtocol] = paths.map({ index in dataSource.getItem(at: index.row)! }).sorted { (it1, it2) -> Bool in
it1.timestamp.compare(it2.timestamp) == .orderedAscending;
};
guard items.count > 1 else {
let texts = items.map({ (it) -> String in
return it.message;
});
handler?(texts);
return;
}
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);
var direction: MessageDirection? = nil;
let rosterModule: RosterModule? = XmppService.instance.getClient(for: self.account)?.modulesManager.getModule(RosterModule.ID);
let rosterStore = rosterModule?.rosterStore;
let texts = items.map { (it) -> String in
if withoutPrefix {
if withTimestamps {
return "[\(formatter.string(from: it.timestamp))] \(it.message)";
} else {
return it.message;
}
} else {
let prefix = (direction == nil || it.state.direction != direction!) ?
"\(it.state.direction == .incoming ? (it.authorNickname ?? rosterStore?.get(for: JID(it.jid))?.name ?? chat.jid.localPart ?? chat.jid.domain) : "Me"):\n" : "";
direction = it.state.direction;
if withTimestamps {
return "\(prefix) [\(formatter.string(from: it.timestamp))] \(it.message ?? "")"
} else {
return "\(prefix) \(it.message ?? "")"
}
}
}
let texts = items.map { (it) -> String? in
return it.copyText(withTimestamp: withTimestamps, withSender: !withoutPrefix);
}.filter { (text) -> Bool in
return text != nil;
}.map { (text) -> String in
return text!;
};
print("got texts", texts);
handler?(texts);

View file

@ -28,63 +28,63 @@ protocol BaseChatViewController_PreviewExtension {
extension BaseChatViewController_PreviewExtension {
func downloadPreview(url: URL, msgId: Int, account: BareJID, jid: BareJID) {
guard Settings.MaxImagePreviewSize.getInt() != 0 else {
return;
}
getHeaders(url: url) { (mimeType, size, errCode) in
if mimeType?.hasPrefix("image/") ?? false && (size ?? Int64.max) < self.getImageDownloadSizeLimit() {
self.downloadImageFile(url: url, completion: { (key) in
let previewKey = key == nil ? nil : "preview:image:\(key!)";
DBChatHistoryStore.instance.updateItem(for: account, with: jid, id: msgId, preview: previewKey ?? "");
})
} else {
DBChatHistoryStore.instance.updateItem(for: account, with: jid, id: msgId, preview: "");
}
}
}
func getHeaders(url: URL, completion: @escaping (String?, Int64?, Int)->Void) {
var request = URLRequest(url: url);
request.httpMethod = "HEAD";
URLSession.shared.dataTask(with: request) { (data, resp, error) in
let response = resp as? HTTPURLResponse;
print("got mime type =", response?.mimeType as Any, "with size", response?.expectedContentLength as Any, "at", url);
// func downloadPreview(url: URL, msgId: Int, account: BareJID, jid: BareJID) {
// guard Settings.MaxImagePreviewSize.getInt() != 0 else {
// return;
// }
//
// getHeaders(url: url) { (mimeType, size, errCode) in
// if mimeType?.hasPrefix("image/") ?? false && (size ?? Int64.max) < self.getImageDownloadSizeLimit() {
// self.downloadImageFile(url: url, completion: { (key) in
// let previewKey = key == nil ? nil : "preview:image:\(key!)";
// DBChatHistoryStore.instance.updateItem(for: account, with: jid, id: msgId, preview: previewKey ?? "");
// })
// } else {
// DBChatHistoryStore.instance.updateItem(for: account, with: jid, id: msgId, preview: "");
// }
// }
// }
//
// func getHeaders(url: URL, completion: @escaping (String?, Int64?, Int)->Void) {
// var request = URLRequest(url: url);
// request.httpMethod = "HEAD";
// URLSession.shared.dataTask(with: request) { (data, resp, error) in
// let response = resp as? HTTPURLResponse;
// print("got mime type =", response?.mimeType as Any, "with size", response?.expectedContentLength as Any, "at", url);
//
// completion(response?.mimeType, response?.expectedContentLength, response?.statusCode ?? 500);
// }.resume();
// }
completion(response?.mimeType, response?.expectedContentLength, response?.statusCode ?? 500);
}.resume();
}
fileprivate func downloadImageFile(url: URL, completion: @escaping (String?)->Void) {
URLSession.shared.downloadTask(with: url, completionHandler: { (tmpUrl, resp, error) in
print("downloaded content of", url, "to", tmpUrl as Any, "response:", resp as Any, "error:", error as Any);
guard let response = resp as? HTTPURLResponse else {
completion(nil);
return;
}
guard tmpUrl != nil && error == nil else {
completion(nil);
return;
}
if let image = UIImage(contentsOfFile: tmpUrl!.path) {
print("loaded image", url, "size", image.size);
ImageCache.shared.set(url: tmpUrl!, mimeType: response.mimeType) { (key) in
completion(key);
}
} else {
completion(nil);
}
}).resume();
}
func getImageDownloadSizeLimit() -> Int64 {
let val = Settings.MaxImagePreviewSize.getInt();
if val > (Int.max / (1024*1024)) {
return Int64(val);
} else {
return Int64(val * 1024 * 1024);
}
}
// fileprivate func downloadImageFile(url: URL, completion: @escaping (String?)->Void) {
// URLSession.shared.downloadTask(with: url, completionHandler: { (tmpUrl, resp, error) in
// print("downloaded content of", url, "to", tmpUrl as Any, "response:", resp as Any, "error:", error as Any);
// guard let response = resp as? HTTPURLResponse else {
// completion(nil);
// return;
// }
// guard tmpUrl != nil && error == nil else {
// completion(nil);
// return;
// }
//
// if let image = UIImage(contentsOfFile: tmpUrl!.path) {
// print("loaded image", url, "size", image.size);
// ImageCache.shared.set(url: tmpUrl!, mimeType: response.mimeType) { (key) in
// completion(key);
// }
// } else {
// completion(nil);
// }
// }).resume();
// }
//
// func getImageDownloadSizeLimit() -> Int64 {
// let val = Settings.MaxImagePreviewSize.getInt();
// if val > (Int.max / (1024*1024)) {
// return Int64(val);
// } else {
// return Int64(val * 1024 * 1024);
// }
// }
}

View file

@ -35,8 +35,9 @@ protocol BaseChatViewController_ShareImageExtension: class {
var account: BareJID! { get }
var jid: BareJID! { get }
func sendAttachment(originalUrl: URL?, uploadedUrl: String, appendix: ChatAttachmentAppendix, completionHandler: (()->Void)?);
func sendMessage(body: String, url: String?, preview: String?, completed: (()->Void)?);
// func sendMessage(body: String, url: String?, preview: String?, completed: (()->Void)?);
func present(_ controller: UIViewController, animated: Bool, completion: (()->Void)?);
}
@ -124,7 +125,7 @@ class BaseChatViewController_SharePickerDelegate: NSObject, URLSessionDelegate,
self.controller = controller;
}
func share(filename: String, url: URL, completionHandler: @escaping (Result<URL,ShareError>)->Void) {
func share(filename: String, url: URL, completionHandler: @escaping (UploadResult)->Void) {
guard url.startAccessingSecurityScopedResource() else {
completionHandler(.failure(.noAccessError));
return;
@ -135,11 +136,10 @@ class BaseChatViewController_SharePickerDelegate: NSObject, URLSessionDelegate,
return;
}
var mimeType: String = "application/octet-stream";
var mimeType: String? = nil;
if let type = values.typeIdentifier, let mimeTypeRef = UTTypeCopyPreferredTagWithClass(type as CFString, kUTTagClassMIMEType) {
mimeType = mimeTypeRef.takeUnretainedValue() as String;
mimeTypeRef.release();
if let type = values.typeIdentifier {
mimeType = UTTypeCopyPreferredTagWithClass(type as CFString, kUTTagClassMIMEType)?.takeRetainedValue() as String?;
}
guard let inputStream = InputStream(url: url) else {
@ -147,11 +147,11 @@ class BaseChatViewController_SharePickerDelegate: NSObject, URLSessionDelegate,
completionHandler(.failure(.noAccessError));
return;
}
self.share(filename: filename, inputStream: inputStream, filesize: size, mimeType: mimeType, completionHandler: { result in
self.share(filename: filename, inputStream: inputStream, filesize: size, mimeType: mimeType ?? "application/octet-stream", completionHandler: { result in
url.stopAccessingSecurityScopedResource();
switch result {
case .success(let getUri):
completionHandler(.success(getUri));
completionHandler(.success(url: getUri, filesize: size, mimeType: mimeType));
case .failure(let error):
completionHandler(.failure(error));
}
@ -235,6 +235,12 @@ class BaseChatViewController_SharePickerDelegate: NSObject, URLSessionDelegate,
self.controller.progressBar.progress = 0;
}
}
enum UploadResult {
case success(url: URL, filesize: Int, mimeType: String?)
case failure(ShareError)
}
}
class BaseChatViewController_ShareFilePickerDelegate: BaseChatViewController_SharePickerDelegate, UIDocumentPickerDelegate {
@ -249,9 +255,14 @@ class BaseChatViewController_ShareFilePickerDelegate: BaseChatViewController_Sha
share(filename: url.lastPathComponent, url: url) { (result) in
switch result {
case .success(let getUri):
print("file uploaded to:", getUri);
self.controller.sendMessage(body: getUri.absoluteString, url: getUri.absoluteString, preview: nil, completed: nil);
case .success(let uploadedUrl, let filesize, let mimetype):
print("file uploaded to:", uploadedUrl);
var appendix = ChatAttachmentAppendix()
appendix.filename = url.lastPathComponent;
appendix.filesize = filesize;
appendix.mimetype = mimetype;
appendix.state = .downloaded;
self.controller.sendAttachment(originalUrl: url, uploadedUrl: uploadedUrl.absoluteString, appendix: appendix, completionHandler: nil);
case .failure(let error):
self.showAlert(shareError: error);
}
@ -288,9 +299,25 @@ class BaseChatViewController_ShareImagePickerDelegate: BaseChatViewController_Sh
switch result {
case .success(let getUri):
print("file uploaded to:", getUri);
ImageCache.shared.set(image: photo) { (key) in
self.controller.sendMessage(body: getUri.absoluteString, url: getUri.absoluteString, preview: "preview:image:\(key)", completed: nil);
var appendix = ChatAttachmentAppendix()
appendix.filename = "image.jpg";
appendix.filesize = data!.count;
appendix.mimetype = "image/jpeg";
appendix.state = .downloaded;
var url: URL? = FileManager.default.temporaryDirectory.appendingPathComponent("image.jpg", isDirectory: false);
do {
try data!.write(to: url!);
} catch {
url = nil;
}
self.controller.sendAttachment(originalUrl: url, uploadedUrl: getUri.absoluteString, appendix: appendix, completionHandler: {
// attachment was sent..
if let url = url, FileManager.default.fileExists(atPath: url.path) {
try? FileManager.default.removeItem(at: url);
}
})
case .failure(let error):
self.showAlert(shareError: error);
}

View file

@ -0,0 +1,128 @@
//
// ChatAttachementsCellView.swift
// Siskin IM
//
// Created by Andrzej Wójcik on 03/01/2020.
// Copyright © 2020 Tigase, Inc. All rights reserved.
//
import UIKit
import MobileCoreServices
import TigaseSwift
class ChatAttachmentsCellView: UICollectionViewCell, UIDocumentInteractionControllerDelegate, UIContextMenuInteractionDelegate {
@IBOutlet var imageField: UIImageView!
private var id: Int {
return item?.id ?? NSNotFound;
};
private var item: ChatAttachment?;
func set(item: ChatAttachment) {
self.item = item;
if #available(iOS 13.0, *), self.interactions.isEmpty {
self.addInteraction(UIContextMenuInteraction(delegate: self));
}
if let fileUrl = DownloadStore.instance.url(for: "\(item.id)") {
if #available(iOS 13.0, *), let imageProvider = MetadataCache.instance.metadata(for: "\(item.id)")?.imageProvider {
imageField.image = UIImage.icon(forFile: fileUrl, mimeType: nil);
imageProvider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil, completionHandler: { (data, error) in
guard let data = data, error == nil else {
return;
}
DispatchQueue.main.async {
guard self.id == item.id else {
return;
}
switch data {
case let image as UIImage:
self.imageField.image = image;
case let data as Data:
self.imageField.image = UIImage(data: data);
default:
break;
}
}
});
} else if let image = UIImage(contentsOfFile: fileUrl.path) {
self.imageField.image = image;
} else {
self.imageField.image = UIImage.icon(forFile: fileUrl, mimeType: nil);
}
} else {
if let mimetype = item.appendix.mimetype, let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimetype as CFString, nil)?.takeRetainedValue() as String? {
imageField.image = UIImage.icon(forUTI: uti);
} else {
imageField.image = UIImage.icon(forUTI: "public.content")
}
}
}
@available(iOS 13.0, *)
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { suggestedActions in
return self.prepareContextMenu();
})
}
@available(iOS 13.0, *)
func prepareContextMenu() -> UIMenu {
guard let item = self.item else {
return UIMenu(title: "");
}
if let localUrl = DownloadStore.instance.url(for: "\(item.id)") {
let items = [
UIAction(title: "Preview", image: UIImage(systemName: "eye.fill"), handler: { action in
print("preview called");
self.open(url: localUrl, preview: true);
}),
UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc"), handler: { action in
guard let text = self.item?.copyText(withTimestamp: Settings.CopyMessagesWithTimestamps.getBool(), withSender: false) else {
return;
}
UIPasteboard.general.strings = [text];
UIPasteboard.general.string = text;
}),
UIAction(title: "Share..", image: UIImage(systemName: "square.and.arrow.up"), handler: { action in
print("share called");
self.open(url: localUrl, preview: false);
}),
UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: [.destructive], handler: { action in
print("delete called");
DownloadStore.instance.deleteFile(for: "\(item.id)");
DBChatHistoryStore.instance.updateItem(for: item.account, with: item.jid, 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) {
print("opening a file:", url, "exists:", FileManager.default.fileExists(atPath: url.path));// "tmp:", tmpUrl);
let documentController = UIDocumentInteractionController(url: url);
documentController.delegate = self;
documentController.name = url.lastPathComponent;
print("detected uti:", documentController.uti, "for:", documentController.url);
if preview && documentController.presentPreview(animated: true) {
self.documentController = documentController;
} else if documentController.presentOptionsMenu(from: CGRect.zero, in: self, animated: true) {
self.documentController = documentController;
}
}
}

View file

@ -0,0 +1,140 @@
//
// ChatAttachementsController.swift
// Siskin IM
//
// Created by Andrzej Wójcik on 03/01/2020.
// Copyright © 2020 Tigase, Inc. All rights reserved.
//
import UIKit
import TigaseSwift
class ChatAttachmentsController: UICollectionViewController, UICollectionViewDelegateFlowLayout {
private var items: [ChatAttachment] = [];
var account: BareJID?;
var jid: BareJID?;
private var loaded: Bool = false;
override func viewDidLoad() {
super.viewDidLoad();
NotificationCenter.default.addObserver(self, selector: #selector(appearanceChanged), name: Appearance.CHANGED, object: nil);
updateAppearance();
if #available(iOS 13.0, *) {
} else {
self.view.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(gesture:))));
}
NotificationCenter.default.addObserver(self, selector: #selector(messageUpdated), name: DBChatHistoryStore.MESSAGE_UPDATED, object: nil);
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated);
self.updateAppearance();
if let account = self.account, let jid = self.jid, !loaded {
self.loaded = true;
DBChatHistoryStore.instance.loadAttachments(for: account, with: jid, 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 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 = "No attachments";
label.font = UIFont.systemFont(ofSize: UIFont.systemFontSize + 2, weight: .medium);
label.numberOfLines = 0;
label.textAlignment = .center;
label.sizeToFit();
label.textColor = Appearance.current.secondaryLabelColor;
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);
}
@objc func appearanceChanged(_ notification: Notification) {
self.updateAppearance();
}
func updateAppearance() {
if #available(iOS 13.0, *) {
overrideUserInterfaceStyle = Appearance.current.isDark ? .dark : .light;
};
self.view.tintColor = Appearance.current.tintColor;
self.collectionView.backgroundColor = Appearance.current.systemBackground;
if let navController = self.navigationController {
navController.navigationBar.barStyle = Appearance.current.navigationBarStyle;
navController.navigationBar.tintColor = Appearance.current.navigationBarTintColor;
navController.navigationBar.barTintColor = Appearance.current.controlBackgroundColor;
navController.navigationBar.setNeedsLayout();
navController.navigationBar.layoutIfNeeded();
navController.navigationBar.setNeedsDisplay();
}
}
@objc func messageUpdated(_ notification: Notification) {
DispatchQueue.main.async {
guard let attachment = notification.object as? ChatAttachment, attachment.account == self.account, attachment.jid == self.jid else {
return;
}
guard let idx = self.items.firstIndex(where: { (att) -> Bool in
return att.id == attachment.id;
}) else {
return;
}
self.items.remove(at: idx);
self.collectionView.deleteItems(at: [IndexPath(item: idx, section: 0)]);
}
}
var documentController: UIDocumentInteractionController?;
@objc func handleLongPress(gesture: UILongPressGestureRecognizer) {
guard gesture.state == .recognized else {
return;
}
let p = gesture.location(in: self.collectionView);
if let indexPath = self.collectionView.indexPathForItem(at: p) {
let item = self.items[indexPath.row];
if let url = DownloadStore.instance.url(for: "\(item.id)") {
let documentController = UIDocumentInteractionController(url: url);
//documentController.delegate = self;
if documentController.presentOptionsMenu(from: CGRect.zero, in: self.collectionView, animated: true) {
self.documentController = documentController;
}
}
}
}
}

View file

@ -0,0 +1,93 @@
//
// ChatAttachment.swift
//
// Siskin IM
// 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. Look for COPYING file in the top folder.
// If not, see https://www.gnu.org/licenses/.
//
import Foundation
import TigaseSwift
public class ChatAttachment: ChatEntry {
let url: String;
var appendix: ChatAttachmentAppendix;
init(id: Int, timestamp: Date, account: BareJID, jid: BareJID, state: MessageState, url: String, authorNickname: String?, authorJid: BareJID?, encryption: MessageEncryption, encryptionFingerprint: String?, appendix: ChatAttachmentAppendix, error: String?) {
self.url = url;
self.appendix = appendix;
super.init(id: id, timestamp: timestamp, account: account, jid: jid, state: state, authorNickname: authorNickname, authorJid: authorJid, encryption: encryption, encryptionFingerprint: encryptionFingerprint, error: error);
}
override public func copyText(withTimestamp: Bool, withSender: Bool) -> String? {
if let prefix = super.copyText(withTimestamp: withTimestamp, withSender: withSender) {
return "\(prefix) \(url)";
} else {
return url;
}
}
}
public struct ChatAttachmentAppendix: Codable {
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
}
}

View file

@ -0,0 +1,102 @@
//
// ChatEntry.swift
//
// Siskin IM
// 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. Look for COPYING file in the top folder.
// If not, see https://www.gnu.org/licenses/.
//
import Foundation
import TigaseSwift
public class ChatEntry: ChatViewItemProtocol {
static let timestampFormatter: DateFormatter = {
let formatter = DateFormatter();
formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "dd.MM.yyyy jj:mm", options: 0, locale: NSLocale.current);
return formatter;
}();
public let id: Int;
public let timestamp: Date;
public let account: BareJID;
public let jid: BareJID;
public let state: MessageState;
// for MUC only but any chat may be a MUC chat...
public let authorNickname: String?;
public let authorJid: BareJID?;
public let error: String?;
public let encryption: MessageEncryption;
public let encryptionFingerprint: String?;
init(id: Int, timestamp: Date, account: BareJID, jid: BareJID, state: MessageState, authorNickname: String?, authorJid: BareJID?, encryption: MessageEncryption, encryptionFingerprint: String?, error: String?) {
self.id = id;
self.timestamp = timestamp;
self.account = account;
self.jid = jid;
self.state = state;
self.authorNickname = authorNickname;
self.authorJid = authorJid;
self.encryption = encryption;
self.encryptionFingerprint = encryptionFingerprint;
self.error = error;
}
public func isMergeable(with chatItem: ChatViewItemProtocol) -> Bool {
guard let item = chatItem as? ChatEntry else {
return false;
}
return self.account == item.account && self.jid == item.jid && self.state.direction == item.state.direction && self.authorNickname == item.authorNickname && self.authorJid == item.authorJid && abs(self.timestamp.timeIntervalSince(item.timestamp)) < allowedTimeDiff() && self.encryption == item.encryption && self.encryptionFingerprint == item.encryptionFingerprint;
}
public func copyText(withTimestamp: Bool, withSender: Bool) -> String? {
if withSender {
switch state.direction {
case .incoming:
if let nickname = authorNickname {
return nickname;
} else if let rosterModule: RosterModule = XmppService.instance.getClient(for: account)?.modulesManager.getModule(RosterModule.ID), let rosterItem = rosterModule.rosterStore.get(for: JID(jid)), let nickname = rosterItem.name {
return nickname;
} else {
return jid.stringValue;
}
case .outgoing:
return "Me:";
}
} else {
if withTimestamp {
return "[\(ChatEntry.timestampFormatter.string(from: timestamp))]";
} else {
return nil;
}
}
}
func allowedTimeDiff() -> TimeInterval {
switch /*Settings.messageGrouping.string() ??*/ "smart" {
case "none":
return -1.0;
case "always":
return 60.0 * 60.0 * 24.0;
default:
return 30.0;
}
}
}

View file

@ -0,0 +1,37 @@
//
// ChatLinkPreview.swift
//
// Siskin IM
// 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. Look for COPYING file in the top folder.
// If not, see https://www.gnu.org/licenses/.
//
import Foundation
import TigaseSwift
class ChatLinkPreview: ChatEntry {
let url: String;
init(id: Int, timestamp: Date, account: BareJID, jid: BareJID, state: MessageState, url: String, authorNickname: String?, authorJid: BareJID?, encryption: MessageEncryption, encryptionFingerprint: String?, error: String?) {
self.url = url;
super.init(id: id, timestamp: timestamp, account: account, jid: jid, state: state, authorNickname: authorNickname, authorJid: authorJid, encryption: encryption, encryptionFingerprint: encryptionFingerprint, error: error);
}
override func copyText(withTimestamp: Bool, withSender: Bool) -> String? {
return nil;
}
}

View file

@ -0,0 +1,42 @@
//
// ChatMessage.swift
//
// Siskin IM
// 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. Look for COPYING file in the top folder.
// If not, see https://www.gnu.org/licenses/.
//
import Foundation
import TigaseSwift
class ChatMessage: ChatEntry {
let message: String;
init(id: Int, timestamp: Date, account: BareJID, jid: BareJID, state: MessageState, message: String, authorNickname: String?, authorJid: BareJID?, encryption: MessageEncryption, encryptionFingerprint: String?, error: String?) {
self.message = message;
super.init(id: id, timestamp: timestamp, account: account, jid: jid, state: state, authorNickname: authorNickname, authorJid: authorJid, encryption: encryption, encryptionFingerprint: encryptionFingerprint, error: error);
}
override func copyText(withTimestamp: Bool, withSender: Bool) -> String? {
if let prefix = super.copyText(withTimestamp: withTimestamp, withSender: withSender) {
return "\(prefix) \(message)";
} else {
return message;
}
}
}

View file

@ -23,186 +23,53 @@
import UIKit
import TigaseSwift
class ChatTableViewCell: UITableViewCell, UIDocumentInteractionControllerDelegate {
class ChatTableViewCell: BaseChatTableViewCell, UIContextMenuInteractionDelegate {
@IBOutlet var avatarView: AvatarView?
@IBOutlet var nicknameView: UILabel?;
@IBOutlet var messageTextView: UILabel!
@IBOutlet var messageFrameView: UIView?
@IBOutlet var timestampView: UILabel?
@IBOutlet var stateView: UILabel?;
@IBOutlet var previewView: UIImageView?;
fileprivate var previewUrl: URL?;
fileprivate var messageLinkTapGestureRecognizer: UITapGestureRecognizer!;
fileprivate var previewViewTapGestureRecognizer: UITapGestureRecognizer?;
fileprivate var originalTextColor: UIColor!;
fileprivate var links: [Link] = [];
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, jj: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, jj:mm", options: 0, locale: NSLocale.current);
// f.timeStyle = .NoStyle;
return f;
})();
private var item: ChatMessage?;
fileprivate func formatTimestamp(_ ts: Date) -> String {
let flags: Set<Calendar.Component> = [.day, .year];
let components = Calendar.current.dateComponents(flags, from: ts, to: Date());
if (components.day! < 1) {
return ChatTableViewCell.todaysFormatter.string(from: ts);
override var backgroundColor: UIColor? {
didSet {
self.messageTextView.backgroundColor = self.backgroundColor;
}
if (components.year! != 0) {
return ChatTableViewCell.fullFormatter.string(from: ts);
} else {
return ChatTableViewCell.defaultFormatter.string(from: ts);
}
}
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
if messageFrameView != nil {
originalTextColor = messageTextView.textColor;
//messageFrameView.backgroundColor = UIColor.li();
messageFrameView?.layer.masksToBounds = true;
messageFrameView?.layer.cornerRadius = 6;
} else {
originalTextColor = messageTextView.textColor;
if previewView != nil {
previewView?.layer.masksToBounds = true;
previewView?.layer.cornerRadius = 6;
}
}
if avatarView != nil {
avatarView!.layer.masksToBounds = true;
avatarView!.layer.cornerRadius = avatarView!.frame.height / 2;
}
originalTextColor = messageTextView.textColor;
messageLinkTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(messageLinkTapGestureDidFire));
messageLinkTapGestureRecognizer.numberOfTapsRequired = 1;
messageLinkTapGestureRecognizer.cancelsTouchesInView = false;
messageTextView.addGestureRecognizer(messageLinkTapGestureRecognizer);
if previewView != nil {
previewViewTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(previewTapGestureDidFire));
messageLinkTapGestureRecognizer.cancelsTouchesInView = false;
previewViewTapGestureRecognizer?.numberOfTapsRequired = 2;
previewView?.addGestureRecognizer(previewViewTapGestureRecognizer!);
}
}
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");
}
print("offset", offset, "view", view);
view.backgroundColor = colors[offset];
}
} else {
super.setSelected(selected, animated: animated);
selectedBackgroundView = nil;
}
// Configure the view for the selected state
}
func set(message item: ChatMessage, downloader: ((URL,Int,BareJID,BareJID)->Void)? = nil) {
var timestamp = formatTimestamp(item.timestamp);
switch item.encryption {
case .decrypted, .notForThisDevice, .decryptionFailed:
timestamp = "\u{1F512} \(timestamp)";
default:
break;
}
switch item.state {
case .incoming_error, .incoming_error_unread:
//elf.message.textColor = NSColor.systemRed;
self.stateView?.text = "\u{203c}";
case .outgoing_unsent:
//self.message.textColor = NSColor.secondaryLabelColor;
self.stateView?.text = "\u{1f4e4}";
case .outgoing_delivered:
//self.message.textColor = nil;
self.stateView?.text = "\u{2713}";
case .outgoing_error, .outgoing_error_unread:
//self.message.textColor = nil;
self.stateView?.text = "\u{203c}";
default:
//self.state?.stringValue = "";
self.stateView?.text = nil;//NSColor.textColor;
}
if stateView == nil {
if item.state.direction == .outgoing {
timestampView?.textColor = UIColor.lightGray;
if item.state.isError {
timestampView?.textColor = UIColor.red;
timestamp = "\(timestamp) Not delivered\u{203c}";
} else if item.state == .outgoing_delivered {
timestamp = "\(timestamp) \u{2713}";
}
}
}
timestampView?.text = timestamp;
self.previewUrl = nil;
self.previewView?.image = nil;
if messageFrameView != nil {
self.messageFrameView?.backgroundColor = item.state.direction == .incoming ? Appearance.current.incomingBubbleColor() : Appearance.current.outgoingBubbleColor();
self.nicknameView?.textColor = Appearance.current.secondaryLabelColor;
self.messageTextView.textColor = self.originalTextColor;
} else {
self.nicknameView?.textColor = Appearance.current.labelColor;
self.messageTextView?.textColor = Appearance.current.secondaryLabelColor;
if #available(iOS 13.0, *) {
messageTextView.addInteraction(UIContextMenuInteraction(delegate: self));
}
}
func set(message item: ChatMessage) {
self.item = item;
super.set(item: item);
self.messageTextView?.textColor = Appearance.current.secondaryLabelColor;
self.links.removeAll();
var previewRange: NSRange? = nil;
var previewSourceUrl: URL? = nil;
let attrText = NSMutableAttributedString(string: item.message);
var first = true;
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: item.message, options: .reportCompletion, range: NSMakeRange(0, item.message.count));
for match in matches {
var url: URL? = nil;
if match.url != nil {
url = match.url;
if first {
first = false;
if (item.preview?.hasPrefix("preview:image:") ?? true) {
let previewKey = item.preview == nil ? nil : String(item.preview!.dropFirst(14));
previewView?.image = ImageCache.shared.get(for: previewKey, ifMissing: {
downloader?(url!, item.id, item.account, item.jid);
})
if previewView?.image != nil && previewKey != nil {
previewUrl = ImageCache.shared.getURL(for: previewKey);
previewRange = match.range;
previewSourceUrl = url;
}
}
}
}
if match.phoneNumber != nil {
url = URL(string: "tel:\(match.phoneNumber!.replacingOccurrences(of: " ", with: "-"))");
@ -217,13 +84,10 @@ class ChatTableViewCell: UITableViewCell, UIDocumentInteractionControllerDelegat
}
if url != nil {
self.links.append(Link(url: url!, range: match.range));
attrText.setAttributes([NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue, NSAttributedString.Key.foregroundColor: (Appearance.current.isDark && Settings.EnableNewUI.getBool()) ? UIColor.blue.adjust(brightness: 0.75) : UIColor.blue], range: match.range);
attrText.setAttributes([NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue, NSAttributedString.Key.foregroundColor: (Appearance.current.isDark) ? UIColor.blue.adjust(brightness: 0.75) : UIColor.blue], range: match.range);
}
}
}
if previewSourceUrl != nil && Settings.SimplifiedLinkToFileIfPreviewIsAvailable.getBool() {
attrText.mutableString.replaceCharacters(in: previewRange!, with: "Link to file");
}
if Settings.EnableMarkdownFormatting.getBool() {
Markdown.applyStyling(attributedString: attrText, font: self.messageTextView.font, showEmoticons:Settings.ShowEmoticons.getBool());
}
@ -234,37 +98,46 @@ class ChatTableViewCell: UITableViewCell, UIDocumentInteractionControllerDelegat
}
if item.state.direction == .incoming {
self.messageTextView.textColor = UIColor.red;
} else {
self.accessoryType = .detailButton;
self.tintColor = UIColor.red;
}
} else {
self.accessoryType = .none;
self.tintColor = self.messageTextView.tintColor;
if item.encryption == .notForThisDevice || item.encryption == .decryptionFailed {
if let messageFrameView = self.messageFrameView {
self.messageTextView.textColor = self.originalTextColor.mix(color: messageFrameView.backgroundColor!, ratio: 0.33);
} else {
self.messageTextView.textColor = Appearance.current.labelColor;
}
self.messageTextView.textColor = Appearance.current.labelColor;
}
}
self.stateView?.textColor = self.messageTextView.textColor;
if messageFrameView != nil {
self.timestampView?.textColor = Appearance.current.labelColor;
} else {
self.timestampView?.textColor = self.messageTextView.textColor;
}
}
@objc func actionMore(_ sender: UIMenuController) {
NotificationCenter.default.post(name: NSNotification.Name("tableViewCellShowEditToolbar"), object: self);
@available(iOS 13.0, *)
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
var cfg = UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { suggestedActions -> UIMenu? in
return self.prepareContextMenu();
};
return cfg;
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
return super.canPerformAction(action, withSender: sender) || action == #selector(actionMore(_:));
@available(iOS 13.0, *)
func prepareContextMenu() -> UIMenu {
let items = [
UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc"), handler: { action in
guard let text = self.item?.copyText(withTimestamp: Settings.CopyMessagesWithTimestamps.getBool(), withSender: false) else {
return;
}
UIPasteboard.general.strings = [text];
UIPasteboard.general.string = text;
}),
UIAction(title: "Share..", image: UIImage(systemName: "square.and.arrow.up"), handler: { action in
guard let text = self.item?.copyText(withTimestamp: Settings.CopyMessagesWithTimestamps.getBool(), withSender: false) else {
return;
}
let activityController = UIActivityViewController(activityItems: [text], applicationActivities: nil);
(UIApplication.shared.delegate as? AppDelegate)?.window?.rootViewController?.present(activityController, animated: true, completion: nil);
}),
UIAction(title: "More..", image: UIImage(systemName: "ellipsis"), handler: { action in
NotificationCenter.default.post(name: Notification.Name("tableViewCellShowEditToolbar"), object: self);
})
];
return UIMenu(title: "", children: items);
}
@objc func messageLinkTapGestureDidFire(_ recognizer: UITapGestureRecognizer) {
guard self.messageTextView.attributedText != nil else {
return;
@ -289,18 +162,7 @@ class ChatTableViewCell: UITableViewCell, UIDocumentInteractionControllerDelegat
UIApplication.shared.open(url.url);
}
}
@objc func previewTapGestureDidFire(_ recognizer: UITapGestureRecognizer) {
guard self.previewView != nil else {
return;
}
let documentController = UIDocumentInteractionController(url: previewUrl!);
documentController.delegate = self;
//documentController.presentPreview(animated: true);
documentController.presentOptionsMenu(from: CGRect.zero, in: self.previewView!, animated: true);
}
class Link {
let url: URL;
let range: NSRange;

View file

@ -114,7 +114,7 @@ class ChatViewController : BaseChatViewControllerWithDataSourceAndContextMenuAnd
}
override func viewDidDisappear(_ animated: Bool) {
NotificationCenter.default.removeObserver(self);
//NotificationCenter.default.removeObserver(self);
super.viewDidDisappear(animated);
}
@ -131,14 +131,17 @@ class ChatViewController : BaseChatViewControllerWithDataSourceAndContextMenuAnd
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if dataSource.count == 0 {
let label = UILabel(frame: CGRect(x: 0, y:0, width: self.view.bounds.size.width, height: self.view.bounds.size.height));
label.text = "No messages available. Pull up to refresh message history.";
label.numberOfLines = 0;
label.textAlignment = .center;
label.transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0);
label.sizeToFit();
label.textColor = Appearance.current.labelColor;
self.tableView.backgroundView = label;
if self.tableView.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 = "No messages available. Pull up to refresh message history.";
label.font = UIFont.systemFont(ofSize: UIFont.systemFontSize + 2, weight: .medium);
label.numberOfLines = 0;
label.textAlignment = .center;
label.transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0);
label.sizeToFit();
label.textColor = Appearance.current.secondaryLabelColor;
self.tableView.backgroundView = label;
}
} else {
self.tableView.backgroundView = nil;
}
@ -150,25 +153,44 @@ class ChatViewController : BaseChatViewControllerWithDataSourceAndContextMenuAnd
return tableView.dequeueReusableCell(withIdentifier: "ChatTableViewCellIncoming", for: indexPath);
}
var continuation = false;
if (indexPath.row + 1) < dataSource.count {
if let prevItem = dataSource.getItem(at: indexPath.row + 1) {
continuation = dsItem.isMergeable(with: prevItem);
}
}
let incoming = dsItem.state.direction == .incoming;
switch dsItem {
case let item as ChatMessage:
var continuation = false;
if Settings.EnableNewUI.getBool() && (indexPath.row + 1) < dataSource.count {
if let prevItem = dataSource.getItem(at: indexPath.row + 1) {
continuation = item.isMergeable(with: prevItem);
}
}
let incoming = item.state.direction == .incoming;
let id = Settings.EnableNewUI.getBool() ? (continuation ? "ChatTableViewCellContinuation" : "ChatTableViewCell") : (incoming ? "ChatTableViewCellIncoming" : "ChatTableViewCellOutgoing");
let id = continuation ? "ChatTableViewMessageContinuationCell" : "ChatTableViewMessageCell";
let cell: ChatTableViewCell = tableView.dequeueReusableCell(withIdentifier: id, for: indexPath) as! ChatTableViewCell;
cell.transform = dataSource.inverted ? CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0) : CGAffineTransform.identity;
let name = incoming ? self.titleView.name : "Me";
cell.avatarView?.set(name: name, avatar: AvatarManager.instance.avatar(for: incoming ? jid : account, on: account), orDefault: AvatarManager.instance.defaultAvatar);
cell.nicknameView?.text = name;
cell.set(message: item, downloader: self.downloadPreview(url:msgId:account:jid:));
cell.setNeedsUpdateConstraints();
cell.updateConstraintsIfNeeded();
cell.set(message: item);
// cell.setNeedsUpdateConstraints();
// cell.updateConstraintsIfNeeded();
return cell;
case let item as ChatAttachment:
let id = continuation ? "ChatTableViewAttachmentContinuationCell" : "ChatTableViewAttachmentCell" ;
let cell: AttachmentChatTableViewCell = tableView.dequeueReusableCell(withIdentifier: id, for: indexPath) as! AttachmentChatTableViewCell;
cell.transform = dataSource.inverted ? CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0) : CGAffineTransform.identity;
let name = incoming ? self.titleView.name : "Me";
cell.avatarView?.set(name: name, avatar: AvatarManager.instance.avatar(for: incoming ? jid : account, on: account), orDefault: AvatarManager.instance.defaultAvatar);
cell.nicknameView?.text = name;
cell.set(attachment: item);
// cell.setNeedsUpdateConstraints();
// cell.updateConstraintsIfNeeded();
return cell;
case let item as ChatLinkPreview:
let id = "ChatTableViewLinkPreviewCell";
let cell: LinkPreviewChatTableViewCell = tableView.dequeueReusableCell(withIdentifier: id, for: indexPath) as! LinkPreviewChatTableViewCell;
cell.transform = dataSource.inverted ? CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0) : CGAffineTransform.identity;
cell.set(linkPreview: item);
return cell;
case let item as SystemMessage:
let cell: ChatTableViewSystemCell = tableView.dequeueReusableCell(withIdentifier: "ChatTableViewSystemCell", for: indexPath) as! ChatTableViewSystemCell;
@ -182,17 +204,27 @@ class ChatViewController : BaseChatViewControllerWithDataSourceAndContextMenuAnd
func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) {
print("accessory button cliecked at", indexPath)
guard let item = dataSource.getItem(at: indexPath.row) as? ChatMessage else {
guard let item = dataSource.getItem(at: indexPath.row) as? ChatEntry, let chat = self.chat as? DBChat else {
return;
}
DispatchQueue.main.async {
let alert = UIAlertController(title: "Details", message: item.error ?? "Unknown error occurred", preferredStyle: .alert);
alert.addAction(UIAlertAction(title: "Resend", style: .default, handler: {(action) in
print("resending message with body", item.message);
let url = item.message.starts(with: "http:") || item.message.starts(with: "https:") ? item.message : nil;
self.sendMessage(body: item.message, url: url, completed: nil);
DBChatHistoryStore.instance.removeItem(for: item.account, with: item.jid, itemId: item.id);
//print("resending message with body", item.message);
switch item {
case let item as ChatMessage:
MessageEventHandler.sendMessage(chat: chat, body: item.message, url: nil);
DBChatHistoryStore.instance.removeItem(for: chat.account, with: chat.jid.bareJid, itemId: item.id);
case let item as ChatAttachment:
let oldLocalFile = DownloadStore.instance.url(for: "\(item.id)");
MessageEventHandler.sendAttachment(chat: chat, originalUrl: oldLocalFile, uploadedUrl: item.url, appendix: item.appendix, completionHandler: {
DBChatHistoryStore.instance.removeItem(for:chat.account, with: chat.jid.bareJid, itemId: item.id);
});
default:
break;
}
}));
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil));
self.present(alert, animated: true, completion: nil);
@ -365,16 +397,19 @@ class ChatViewController : BaseChatViewControllerWithDataSourceAndContextMenuAnd
return;
}
sendMessage(body: text!, completed: {() in
DispatchQueue.main.async {
self.messageText = nil;
}
});
MessageEventHandler.sendMessage(chat: self.chat as! DBChat, body: text, url: nil);
DispatchQueue.main.async {
self.messageText = nil;
}
}
func sendMessage(body: String, url: String? = nil, preview: String? = nil, completed: (()->Void)?) {
MessageEventHandler.sendMessage(chat: self.chat as! DBChat, body: body, url: url);
completed?();
func sendAttachment(originalUrl: URL?, uploadedUrl: String, appendix: ChatAttachmentAppendix, completionHandler: (() -> Void)?) {
guard let chat = self.chat as? DBChat else {
completionHandler?();
return;
}
MessageEventHandler.sendAttachment(chat: chat, originalUrl: originalUrl, uploadedUrl: uploadedUrl, appendix: appendix, completionHandler: completionHandler);
}
}

View file

@ -427,7 +427,11 @@ class ChatViewDataSource {
let unsent1 = it1.state == .outgoing_unsent;
let unsent2 = it2.state == .outgoing_unsent;
if unsent1 == unsent2 {
return it1.timestamp.compare(it2.timestamp);
let result = it1.timestamp.compare(it2.timestamp);
guard result == .orderedSame else {
return result;
}
return it1.id < it2.id ? .orderedAscending : .orderedDescending;
} else {
if unsent1 {
return .orderedDescending;
@ -482,6 +486,10 @@ class SystemMessage: ChatViewItemProtocol {
func isMergeable(with item: ChatViewItemProtocol) -> Bool {
return false;
}
func copyText(withTimestamp: Bool, withSender: Bool) -> String? {
return nil;
}
enum Kind {
case unreadMessages

View file

@ -32,4 +32,5 @@ public protocol ChatViewItemProtocol: class {
var encryptionFingerprint: String? { get };
func isMergeable(with item: ChatViewItemProtocol) -> Bool;
func copyText(withTimestamp: Bool, withSender: Bool) -> String?;
}

View file

@ -80,17 +80,13 @@ class ChatsListViewController: CustomTableViewController {
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cellIdentifier = Settings.EnableNewUI.getBool() ? "ChatsListTableViewCellNew" : "ChatsListTableViewCell";
let cellIdentifier = "ChatsListTableViewCellNew";
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath as IndexPath) as! ChatsListTableViewCell;
if let item = dataSource!.item(at: indexPath) {
cell.nameLabel.textColor = Appearance.current.labelColor;
cell.nameLabel.font = item.unread > 0 ? UIFont.boldSystemFont(ofSize: cell.nameLabel.font.pointSize) : UIFont.systemFont(ofSize: cell.nameLabel.font.pointSize);
// if Settings.EnableNewUI.getBool() {
cell.lastMessageLabel.textColor = Appearance.current.labelColor;
// } else {
cell.lastMessageLabel.textColor = Appearance.current.secondaryLabelColor;
// }
if item.lastMessage != nil && Settings.EnableMarkdownFormatting.getBool() {
let msg = NSMutableAttributedString(string: item.lastMessage!);
Markdown.applyStyling(attributedString: msg, font: cell.lastMessageLabel.font, showEmoticons: Settings.ShowEmoticons.getBool())

View file

@ -0,0 +1,82 @@
//
// LinkPreviewChatTableViewCell.swift
//
// Siskin IM
// 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. Look for COPYING file in the top folder.
// If not, see https://www.gnu.org/licenses/.
//
import UIKit
import LinkPresentation
class LinkPreviewChatTableViewCell: BaseChatTableViewCell {
var linkView: UIView? {
didSet {
if let value = oldValue {
if #available(iOS 13.0, *) {
(value as! LPLinkView).metadata = LPLinkMetadata();
}
value.removeFromSuperview();
}
if let value = linkView {
self.addSubview(value);
NSLayoutConstraint.activate([
value.topAnchor.constraint(equalTo: self.topAnchor, constant: 2),
value.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -4),
value.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 44),
value.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor, constant: -22)
]);
}
}
}
func set(linkPreview item: ChatLinkPreview) {
super.set(item: item);
if #available(iOS 13.0, *) {
var metadata = MetadataCache.instance.metadata(for: "\(item.id)");
var isNew = false;
let url = URL(string: item.url)!;
if (metadata == nil) {
metadata = LPLinkMetadata();
metadata!.originalURL = url;
isNew = true;
}
//if self.linkView == nil {
self.linkView = LPLinkView(url: url);
linkView?.setContentCompressionResistancePriority(.defaultHigh, for: .vertical);
linkView?.setContentCompressionResistancePriority(.defaultLow, for: .horizontal);
linkView?.translatesAutoresizingMaskIntoConstraints = false;
//};
let linkView = self.linkView as! LPLinkView;
linkView.metadata = metadata!;
linkView.overrideUserInterfaceStyle = Appearance.current.isDark ? .dark : .light;
if isNew {
MetadataCache.instance.generateMetadata(for: url, withId: "\(item.id)", completionHandler: { [weak linkView] meta1 in
guard let meta = meta1 else {
return;
}
DBChatHistoryStore.instance.itemUpdated(withId: item.id, for: item.account, with: item.jid);
})
}
}
}
}

View file

@ -97,6 +97,7 @@ class MucChatSettingsViewController: CustomTableViewController, UIImagePickerCon
})
dispatchGroup.enter();
vcardTempModule.retrieveVCard(from: room.jid, onSuccess: { (vcard) in
XmppService.instance.dbVCardsCache.updateVCard(for: self.room.roomJid, on: self.account, vcard: vcard);
DispatchQueue.main.async {
self.canEditVCard = true;
dispatchGroup.leave();
@ -185,6 +186,15 @@ class MucChatSettingsViewController: CustomTableViewController, UIImagePickerCon
}
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "chatShowAttachments" {
if let attachmentsController = segue.destination as? ChatAttachmentsController {
attachmentsController.account = self.account;
attachmentsController.jid = self.room.roomJid;
}
}
}
@objc func editClicked(_ sender: UIBarButtonItem) {
let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet);
alertController.addAction(UIAlertAction(title: "Rename chat", style: .default, handler: { (action) in

View file

@ -107,16 +107,16 @@ class MucChatViewController: BaseChatViewControllerWithDataSourceAndContextMenuA
return tableView.dequeueReusableCell(withIdentifier: "MucChatTableViewCellIncoming", for: indexPath);
}
var continuation = false;
if (indexPath.row + 1) < dataSource.count {
if let prevItem = dataSource.getItem(at: indexPath.row + 1) {
continuation = dbItem.isMergeable(with: prevItem);
}
}
switch dbItem {
case let item as ChatMessage:
var continuation = false;
if Settings.EnableNewUI.getBool() && (indexPath.row + 1) < dataSource.count {
if let prevItem = dataSource.getItem(at: indexPath.row + 1) {
continuation = item.isMergeable(with: prevItem);
}
}
let id = Settings.EnableNewUI.getBool() ? (continuation ? "MucChatTableViewCellContinuation" : "MucChatTableViewCell") : (item.state.direction == .incoming ? "MucChatTableViewCellIncoming" : "MucChatTableViewCellOutgoing")
let id = continuation ? "MucChatTableViewMessageContinuationCell" : "MucChatTableViewMessageCell";
let cell = tableView.dequeueReusableCell(withIdentifier: id, for: indexPath) as! ChatTableViewCell;
cell.transform = dataSource.inverted ? CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0) : CGAffineTransform.identity;
@ -131,9 +131,34 @@ class MucChatViewController: BaseChatViewControllerWithDataSourceAndContextMenuA
}
}
cell.nicknameView?.text = item.authorNickname;
cell.set(message: item, downloader: downloadPreview(url:msgId:account:jid:));
cell.set(message: item);
cell.backgroundColor = Appearance.current.systemBackground;
return cell;
case let item as ChatAttachment:
let id = continuation ? "MucChatTableViewAttachmentContinuationCell" : "MucChatTableViewAttachmentCell";
let cell: AttachmentChatTableViewCell = tableView.dequeueReusableCell(withIdentifier: id, for: indexPath) as! AttachmentChatTableViewCell;
cell.transform = dataSource.inverted ? CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0) : CGAffineTransform.identity;
if cell.avatarView != nil {
if let senderJid = item.state.direction == .incoming ? item.authorJid : item.account {
cell.avatarView?.set(name: item.authorNickname, avatar: AvatarManager.instance.avatar(for: senderJid, on: item.account), orDefault: AvatarManager.instance.defaultAvatar);
} else if let nickname = item.authorNickname, let photoHash = self.room?.presences[nickname]?.presence.vcardTempPhoto {
cell.avatarView?.set(name: item.authorNickname, avatar: AvatarManager.instance.avatar(withHash: photoHash), orDefault: AvatarManager.instance.defaultAvatar);
} else {
cell.avatarView?.set(name: item.authorNickname, avatar: nil, orDefault: AvatarManager.instance.defaultAvatar);
}
}
cell.nicknameView?.text = item.authorNickname;
cell.set(attachment: item);
cell.setNeedsUpdateConstraints();
cell.updateConstraintsIfNeeded();
return cell;
case let item as ChatLinkPreview:
let id = "MucChatTableViewLinkPreviewCell";
let cell: LinkPreviewChatTableViewCell = tableView.dequeueReusableCell(withIdentifier: id, for: indexPath) as! LinkPreviewChatTableViewCell;
cell.transform = dataSource.inverted ? CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: 0) : CGAffineTransform.identity;
cell.set(linkPreview: item);
return cell;
case let item as SystemMessage:
let cell: ChatTableViewSystemCell = tableView.dequeueReusableCell(withIdentifier: "MucChatTableViewSystemCell", for: indexPath) as! ChatTableViewSystemCell;
cell.set(item: item);
@ -223,22 +248,21 @@ class MucChatViewController: BaseChatViewControllerWithDataSourceAndContextMenuA
return;
}
self.sendMessage(body: text!, url: nil, completed: {() in
DispatchQueue.main.async {
self.messageText = nil;
}
});
self.room!.sendMessage(text, url: nil, additionalElements: []);
DispatchQueue.main.async {
self.messageText = nil;
}
}
@IBAction func shareClicked(_ sender: UIButton) {
self.showPhotoSelector(sender);
}
func sendMessage(body: String, url: String? = nil, preview: String? = nil, completed: (()->Void)?) {
self.room!.sendMessage(body, url: url, additionalElements: []);
completed?();
func sendAttachment(originalUrl: URL?, uploadedUrl: String, appendix: ChatAttachmentAppendix, completionHandler: (() -> Void)?) {
self.room!.sendMessage(uploadedUrl, url: uploadedUrl, additionalElements: []);
completionHandler?();
}
@objc func roomInfoClicked() {
print("room info for", account, room?.roomJid, "clicked!");
guard let settingsController = self.storyboard?.instantiateViewController(withIdentifier: "MucChatSettingsViewController") as? MucChatSettingsViewController else {

View file

@ -104,6 +104,7 @@ class ContactViewController: CustomTableViewController {
var sections: [Sections] = [.basic];
if chat != nil {
sections.append(.settings);
sections.append(.attachments);
sections.append(.encryption);
}
if phones.count > 0 {
@ -131,6 +132,8 @@ class ContactViewController: CustomTableViewController {
return 1;
case .settings:
return 2;
case .attachments:
return 1;
case .encryption:
return omemoIdentities.count + 1;
case .phones:
@ -191,6 +194,9 @@ class ContactViewController: CustomTableViewController {
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);
@ -311,6 +317,8 @@ class ContactViewController: CustomTableViewController {
return;
case .settings:
return;
case .attachments:
return;
case .encryption:
if indexPath.row == 0 {
// handle change of encryption method!
@ -365,6 +373,15 @@ class ContactViewController: CustomTableViewController {
}
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "chatShowAttachments" {
if let attachmentsController = segue.destination as? ChatAttachmentsController {
attachmentsController.account = self.account;
attachmentsController.jid = self.jid;
}
}
}
func getVCardEntryTypeLabel(for type: VCard.EntryType) -> String? {
switch type {
case .home:
@ -416,7 +433,6 @@ class ContactViewController: CustomTableViewController {
break;
case .failure(let err):
AccountSettings.pushHash(account).set(int: 0);
Dis
}
});
}
@ -459,6 +475,7 @@ class ContactViewController: CustomTableViewController {
enum Sections {
case basic
case settings
case attachments
case encryption
case phones
case emails
@ -470,6 +487,8 @@ class ContactViewController: CustomTableViewController {
return "";
case .settings:
return "Settings";
case .attachments:
return "";
case .encryption:
return "Encryption";
case .phones:

View file

@ -32,11 +32,9 @@ open class DBChatHistoryStore: Logger {
public static let MESSAGE_REMOVED = Notification.Name("messageRemoved");
fileprivate static let CHAT_GET_ID_WITH_ACCOUNT_PARTICIPANT_AND_STANZA_ID = "SELECT id FROM chat_history WHERE account = :account AND jid = :jid AND stanza_id = :stanzaId";
fileprivate static let CHAT_MSG_APPEND = "INSERT INTO chat_history (account, jid, author_jid, author_nickname, timestamp, item_type, data, stanza_id, state, encryption, fingerprint) VALUES (:account, :jid, :author_jid, :author_nickname, :timestamp, :item_type, :data, :stanza_id, :state, :encryption, :fingerprint)";
fileprivate static let CHAT_MSG_APPEND = "INSERT INTO chat_history (account, jid, author_jid, author_nickname, timestamp, item_type, data, stanza_id, state, encryption, fingerprint, appendix) VALUES (:account, :jid, :author_jid, :author_nickname, :timestamp, :item_type, :data, :stanza_id, :state, :encryption, :fingerprint, :appendix)";
fileprivate static let CHAT_MSGS_COUNT = "SELECT count(id) FROM chat_history WHERE account = :account AND jid = :jid";
fileprivate static let CHAT_MSGS_DELETE = "DELETE FROM chat_history WHERE account = :account AND jid = :jid";
fileprivate static let CHAT_MSGS_GET = "SELECT id, author_jid, author_nickname, timestamp, item_type, data, state, preview, encryption, fingerprint FROM chat_history WHERE account = :account AND jid = :jid ORDER BY timestamp DESC LIMIT :limit OFFSET :offset"
fileprivate static let CHAT_MSG_UPDATE_PREVIEW = "UPDATE chat_history SET preview = :preview WHERE id = :id";
fileprivate static let CHAT_MSGS_MARK_AS_READ = "UPDATE chat_history SET state = case state when \(MessageState.incoming_error_unread.rawValue) then \(MessageState.incoming_error.rawValue) when \(MessageState.outgoing_error_unread.rawValue) then \(MessageState.outgoing_error.rawValue) else \(MessageState.incoming.rawValue) end WHERE account = :account AND jid = :jid AND state in (\(MessageState.incoming_unread.rawValue), \(MessageState.incoming_error_unread.rawValue), \(MessageState.outgoing_error_unread.rawValue))";
fileprivate static let CHAT_MSGS_MARK_AS_READ_BEFORE = "UPDATE chat_history SET state = case state when \(MessageState.incoming_error_unread.rawValue) then \(MessageState.incoming_error.rawValue) when \(MessageState.outgoing_error_unread.rawValue) then \(MessageState.outgoing_error.rawValue) else \(MessageState.incoming.rawValue) end WHERE account = :account AND jid = :jid AND timestamp <= :before AND state in (\(MessageState.incoming_unread.rawValue), \(MessageState.incoming_error_unread.rawValue), \(MessageState.outgoing_error_unread.rawValue))";
fileprivate static let CHAT_MSG_CHANGE_STATE = "UPDATE chat_history SET state = :newState, timestamp = COALESCE(:timestamp, timestamp) WHERE id = :id AND (:oldState IS NULL OR state = :oldState)";
@ -50,17 +48,18 @@ open class DBChatHistoryStore: Logger {
fileprivate lazy var msgGetIdWithAccountPariticipantAndStanzaIdStmt: DBStatement! = try? self.dbConnection.prepareStatement(DBChatHistoryStore.CHAT_GET_ID_WITH_ACCOUNT_PARTICIPANT_AND_STANZA_ID);
fileprivate lazy var msgsMarkAsReadStmt: DBStatement! = try? self.dbConnection.prepareStatement(DBChatHistoryStore.CHAT_MSGS_MARK_AS_READ);
fileprivate lazy var msgsMarkAsReadBeforeStmt: DBStatement! = try? self.dbConnection.prepareStatement(DBChatHistoryStore.CHAT_MSGS_MARK_AS_READ_BEFORE);
fileprivate lazy var msgUpdatePreviewStmt: DBStatement! = try? self.dbConnection.prepareStatement(DBChatHistoryStore.CHAT_MSG_UPDATE_PREVIEW);
fileprivate lazy var msgUpdateStateStmt: DBStatement! = try? self.dbConnection.prepareStatement(DBChatHistoryStore.CHAT_MSG_CHANGE_STATE);
open lazy var checkItemAlreadyAddedStmt: DBStatement! = try? self.dbConnection.prepareStatement(DBChatHistoryStore.MSG_ALREADY_ADDED);
fileprivate lazy var listUnreadChatsStmt: DBStatement! = try? self.dbConnection.prepareStatement("SELECT DISTINCT account, jid FROM chat_history WHERE state in (\(MessageState.incoming_unread.rawValue), \(MessageState.incoming_error_unread.rawValue), \(MessageState.outgoing_error_unread.rawValue))");
fileprivate lazy var getMessagePositionStmt: DBStatement! = try? self.dbConnection.prepareStatement("SELECT count(id) FROM chat_history WHERE account = :account AND jid = :jid AND id <> :msgId AND timestamp < (SELECT timestamp FROM chat_history WHERE id = :msgId)");
fileprivate lazy var getMessagePositionStmtInverted: DBStatement! = try? self.dbConnection.prepareStatement("SELECT count(id) FROM chat_history WHERE account = :account AND jid = :jid AND id <> :msgId AND timestamp > (SELECT timestamp FROM chat_history WHERE id = :msgId)");
fileprivate lazy var getMessagePositionStmtInverted: DBStatement! = try? self.dbConnection.prepareStatement("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))) AND timestamp > (SELECT timestamp FROM chat_history WHERE id = :msgId)");
fileprivate lazy var markMessageAsErrorStmt: DBStatement! = try? self.dbConnection.prepareStatement("UPDATE chat_history SET state = :state, error = :error WHERE id = :id");
fileprivate lazy var getMessageErrorDetails: DBStatement! = try? self.dbConnection.prepareStatement("SELECT error FROM chat_history WHERE id = ?");
fileprivate lazy var getChatMessageWithIdStmt: DBStatement! = try! self.dbConnection.prepareStatement("SELECT id, author_nickname, author_jid, timestamp, item_type, data, state, preview, encryption, fingerprint, error FROM chat_history WHERE id = :id");
fileprivate lazy var getChatMessageWithIdStmt: DBStatement! = try! self.dbConnection.prepareStatement("SELECT id, account, jid, author_nickname, author_jid, timestamp, item_type, data, state, preview, encryption, fingerprint, error, appendix FROM chat_history WHERE id = :id");
fileprivate let getUnsentMessagesForAccountStmt: DBStatement;
fileprivate let getChatMessagesStmt: DBStatement;
fileprivate let getChatAttachmentsStmt: DBStatement;
fileprivate let updateItemStmt: DBStatement;
fileprivate lazy var removeItemStmt: DBStatement! = try! self.dbConnection.prepareStatement("DELETE FROM chat_history WHERE id = :id");
fileprivate lazy var countUnsentMessagesStmt: DBStatement! = try! self.dbConnection.prepareStatement("SELECT count(id) FROM chat_history WHERE state = \(MessageState.outgoing_unsent.rawValue)");
@ -74,33 +73,55 @@ open class DBChatHistoryStore: Logger {
self.dispatcher = QueueDispatcher(label: "chat_history_store");
self.dbConnection = dbConnection;
self.getUnsentMessagesForAccountStmt = try! self.dbConnection.prepareStatement("SELECT ch.account as account, ch.jid as jid, ch.data as data, ch.stanza_id as stanza_id, ch.encryption as encryption FROM chat_history ch WHERE ch.account = :account AND ch.state = \(MessageState.outgoing_unsent.rawValue) ORDER BY timestamp ASC");
self.getChatMessagesStmt = try! DBConnection.main.prepareStatement("SELECT id, author_nickname, author_jid, timestamp, item_type, data, state, preview, encryption, fingerprint, error FROM chat_history WHERE account = :account AND jid = :jid ORDER BY timestamp DESC LIMIT :limit OFFSET :offset");
self.getChatMessagesStmt = try! dbConnection.prepareStatement("SELECT id, author_nickname, author_jid, timestamp, item_type, data, state, preview, encryption, fingerprint, error, appendix FROM chat_history WHERE account = :account AND jid = :jid AND (:showLinkPreviews OR item_type IN (\(ItemType.message.rawValue), \(ItemType.attachment.rawValue))) ORDER BY timestamp DESC LIMIT :limit OFFSET :offset");
self.getChatAttachmentsStmt = try! dbConnection.prepareStatement("SELECT id, author_nickname, author_jid, timestamp, item_type, data, state, preview, encryption, fingerprint, error, appendix FROM chat_history WHERE account = :account AND jid = :jid AND item_type = \(ItemType.attachment.rawValue) ORDER BY timestamp DESC");
self.updateItemStmt = try! dbConnection.prepareStatement("UPDATE chat_history SET appendix = :appendix WHERE id = :id")
super.init();
NotificationCenter.default.addObserver(self, selector: #selector(DBChatHistoryStore.accountRemoved), name: NSNotification.Name(rawValue: "accountRemoved"), object: nil);
NotificationCenter.default.addObserver(self, selector: #selector(DBChatHistoryStore.imageRemovedFromCache), name: ImageCache.DISK_CACHE_IMAGE_REMOVED, object: nil);
}
public func appendItem(for account: BareJID, with jid: BareJID, state inState: MessageState, authorNickname: String? = nil, authorJid: BareJID? = nil, type: ItemType = .message, timestamp: Date, stanzaId id: String?, data: String, chatState: ChatState? = nil, errorCondition: ErrorCondition? = nil, errorMessage: String? = nil, encryption: MessageEncryption, encryptionFingerprint: String?, completionHandler: ((Int)->Void)?) {
public func appendItem(for account: BareJID, with jid: BareJID, state inState: MessageState, authorNickname: String? = nil, authorJid: BareJID? = nil, type: ItemType = .message, timestamp inTimestamp: Date, stanzaId id: String?, data: String, chatState: ChatState? = nil, errorCondition: ErrorCondition? = nil, errorMessage: String? = nil, encryption: MessageEncryption, encryptionFingerprint: String?, chatAttachmentAppendix: ChatAttachmentAppendix? = nil, skipItemAlreadyExists: Bool = false, completionHandler: ((Int)->Void)?) {
dispatcher.async {
let timestamp = Date(timeIntervalSince1970: Double(Int64(inTimestamp.timeIntervalSince1970 * 1000)) / 1000);
guard !inState.isError || id == nil || !self.processOutgoingError(for: account, with: jid, stanzaId: id!, errorCondition: errorCondition, errorMessage: errorMessage) else {
return;
}
guard !self.checkItemAlreadyAdded(for: account, with: jid, authorNickname: authorNickname, type: type, timestamp: timestamp, direction: inState.direction, stanzaId: id, data: data) else {
guard skipItemAlreadyExists || !self.checkItemAlreadyAdded(for: account, with: jid, authorNickname: authorNickname, type: type, timestamp: timestamp, direction: inState.direction, stanzaId: id, data: data) else {
return;
}
let state = self.calculateState(for: account, with: jid, timestamp: timestamp, state: inState);
let params:[String:Any?] = ["account" : account, "jid" : jid, "timestamp": timestamp, "data": data, "item_type": type.rawValue, "state": state.rawValue, "stanza_id": id, "author_jid" : authorJid, "author_nickname": authorNickname, "encryption": encryption.rawValue, "fingerprint": encryptionFingerprint]
var appendix: String? = nil;
if let attachmentAppendix = chatAttachmentAppendix {
if let appendixData = try? JSONEncoder().encode(attachmentAppendix) {
appendix = String(data: appendixData, encoding: .utf8);
}
}
let params:[String:Any?] = ["account" : account, "jid" : jid, "timestamp": timestamp, "data": data, "item_type": type.rawValue, "state": state.rawValue, "stanza_id": id, "author_jid" : authorJid, "author_nickname": authorNickname, "encryption": encryption.rawValue, "fingerprint": encryptionFingerprint, "appendix": appendix]
guard let msgId = try! self.appendMessageStmt.insert(params) else {
return;
}
completionHandler?(msgId);
let item = ChatMessage(id: msgId, timestamp: timestamp, account: account, jid: jid, state: state, message: data, authorNickname: authorNickname, authorJid: authorJid, encryption: encryption, encryptionFingerprint: encryptionFingerprint, error: errorMessage);
DBChatStore.instance.newMessage(for: account, with: jid, timestamp: timestamp, message: encryption.message() ?? data, state: state, remoteChatState: state.direction == .incoming ? chatState : nil) {
NotificationCenter.default.post(name: DBChatHistoryStore.MESSAGE_NEW, object: item);
var item: ChatViewItemProtocol?;
switch type {
case .message:
item = ChatMessage(id: msgId, timestamp: timestamp, account: account, jid: jid, state: state, message: data, authorNickname: authorNickname, authorJid: authorJid, encryption: encryption, encryptionFingerprint: encryptionFingerprint, error: errorMessage);
case .attachment:
item = ChatAttachment(id: msgId, timestamp: timestamp, account: account, jid: jid, state: state, url: data, authorNickname: authorNickname, authorJid: authorJid, encryption: encryption, encryptionFingerprint: encryptionFingerprint, appendix: chatAttachmentAppendix ?? ChatAttachmentAppendix(), error: errorMessage);
case .linkPreview:
if #available(iOS 13.0, *), Settings.linkPreviews.bool() {
item = ChatLinkPreview(id: msgId, timestamp: timestamp, account: account, jid: jid, state: state, url: data, authorNickname: authorNickname, authorJid: authorJid, encryption: encryption, encryptionFingerprint: encryptionFingerprint, error: errorMessage);
}
}
if item != nil {
DBChatStore.instance.newMessage(for: account, with: jid, timestamp: timestamp, message: encryption.message() ?? data, state: state, remoteChatState: state.direction == .incoming ? chatState : nil) {
NotificationCenter.default.post(name: DBChatHistoryStore.MESSAGE_NEW, object: item);
}
}
}
}
@ -137,7 +158,7 @@ open class DBChatHistoryStore: Logger {
dispatcher.async {
// let count = try! self.msgsCountStmt.scalar(["account": account, "jid": jid] as [String : Any?]) ?? 0;
if before != nil {
let params: [String: Any?] = ["account": account, "jid": jid, "msgId": before!];
let params: [String: Any?] = ["account": account, "jid": jid, "msgId": before!, "showLinkPreviews": self.linkPreviews];
let offset = try! self.getMessagePositionStmtInverted.scalar(params)!;
completionHandler( self.history(for: account, jid: jid, offset: offset, limit: limit));
} else {
@ -149,7 +170,7 @@ open class DBChatHistoryStore: Logger {
open func history(for account: BareJID, jid: BareJID, before: Int? = nil, limit: Int) -> [ChatViewItemProtocol] {
return dispatcher.sync {
if before != nil {
let offset = try! getMessagePositionStmtInverted.scalar(["account": account, "jid": jid, "msgId": before!])!;
let offset = try! getMessagePositionStmtInverted.scalar(["account": account, "jid": jid, "msgId": before!, "showLinkPreviews": self.linkPreviews])!;
return history(for: account, jid: jid, offset: offset, limit: limit);
} else {
return history(for: account, jid: jid, offset: 0, limit: limit);
@ -158,7 +179,7 @@ open class DBChatHistoryStore: Logger {
}
fileprivate func history(for account: BareJID, jid: BareJID, offset: Int, limit: Int) -> [ChatViewItemProtocol] {
let params: [String: Any?] = ["account": account, "jid": jid, "offset": offset, "limit": limit];
let params: [String: Any?] = ["account": account, "jid": jid, "offset": offset, "limit": limit, "showLinkPreviews": linkPreviews];
return try! getChatMessagesStmt.query(params) { (cursor) -> ChatViewItemProtocol? in
return itemFrom(cursor: cursor, for: account, with: jid);
}
@ -223,16 +244,7 @@ open class DBChatHistoryStore: Logger {
self.itemRemoved(withId: itemId, for: account, with: jid);
}
}
open func updateItem(for account: BareJID, with jid: BareJID, id: Int, preview: String?) {
dispatcher.async {
let params: [String:Any?] = ["id": id, "preview": preview];
if try! self.msgUpdatePreviewStmt.update(params) > 0 {
self.itemUpdated(withId: id, for: account, with: jid);
}
}
}
fileprivate func getMessageIdInt(account: BareJID, jid: BareJID, stanzaId: String?) -> Int? {
guard stanzaId != nil else {
return nil;
@ -268,6 +280,46 @@ open class DBChatHistoryStore: Logger {
}
}
open func updateItem(for account: BareJID, with jid: BareJID, id: Int, updateAppendix updateFn: @escaping (inout ChatAttachmentAppendix)->Void) {
dispatcher.async {
var params: [String: Any?] = ["id": id];
guard let item = try! self.getChatMessageWithIdStmt.findFirst(params, map: { (cursor) in
return self.itemFrom(cursor: cursor, for: account, with: jid)
}) as? ChatAttachment else {
return;
}
updateFn(&item.appendix);
if let data = try? JSONEncoder().encode(item.appendix), let dataStr = String(data: data, encoding: .utf8) {
params["appendix"] = dataStr;
try! self.updateItemStmt.update(params)
}
DispatchQueue.main.async {
NotificationCenter.default.post(name: DBChatHistoryStore.MESSAGE_UPDATED, object: item);
}
}
}
open func updateItem(id: Int, updateAppendix updateFn: @escaping (inout ChatAttachmentAppendix)->Void) {
dispatcher.async {
var params: [String: Any?] = ["id": id];
guard let item = try! self.getChatMessageWithIdStmt.findFirst(params, map: { (cursor) -> ChatViewItemProtocol? in
let account: BareJID = cursor["account"]!;
let jid: BareJID = cursor["jid"]!;
return self.itemFrom(cursor: cursor, for: account, with: jid)
}) as? ChatAttachment else {
return;
}
updateFn(&item.appendix);
if let data = try? JSONEncoder().encode(item.appendix), let dataStr = String(data: data, encoding: .utf8) {
params["appendix"] = dataStr;
try! self.updateItemStmt.update(params)
}
DispatchQueue.main.async {
NotificationCenter.default.post(name: DBChatHistoryStore.MESSAGE_UPDATED, object: item);
}
}
}
open func loadUnsentMessage(for account: BareJID, completionHandler: @escaping (BareJID,BareJID,String,String,MessageEncryption)->Void) {
dispatcher.async {
try! self.getUnsentMessagesForAccountStmt.query(["account": account] as [String : Any?], forEach: { (cursor) in
@ -281,7 +333,7 @@ open class DBChatHistoryStore: Logger {
}
}
fileprivate func itemUpdated(withId id: Int, for account: BareJID, with jid: BareJID) {
func itemUpdated(withId id: Int, for account: BareJID, with jid: BareJID) {
dispatcher.async {
let params: [String: Any?] = ["id": id]
try! self.getChatMessageWithIdStmt.query(params, forEach: { (cursor) in
@ -306,26 +358,61 @@ open class DBChatHistoryStore: Logger {
}
}
@objc open func imageRemovedFromCache(_ notification: NSNotification) {
if let data = notification.userInfo {
let url = data["url"] as! URL;
_ = try! dbConnection.prepareStatement("UPDATE chat_history SET preview = null WHERE preview = ?").update("preview:image:\(url.pathComponents.last!)");
public func loadAttachments(for account: BareJID, with jid: BareJID, completionHandler: @escaping ([ChatAttachment])->Void) {
let params: [String: Any?] = ["account": account, "jid": jid];
dispatcher.async {
let attachments: [ChatAttachment] = try! self.getChatAttachmentsStmt.query(params, map: { cursor -> ChatAttachment? in
return self.itemFrom(cursor: cursor, for: account, with: jid) as? ChatAttachment;
});
completionHandler(attachments);
}
}
fileprivate var linkPreviews: Bool {
if #available(iOS 13.0, *) {
return Settings.linkPreviews.bool();
} else {
return false;
}
}
fileprivate func itemFrom(cursor: DBCursor, for account: BareJID, with jid: BareJID) -> ChatViewItemProtocol? {
func itemFrom(cursor: DBCursor, for account: BareJID, with jid: BareJID) -> ChatViewItemProtocol? {
let id: Int = cursor["id"]!;
let stateInt: Int = cursor["state"]!;
let timestamp: Date = cursor["timestamp"]!;
let message: String = cursor["data"]!;
guard let entryType = ItemType(rawValue: cursor["item_type"]!) else {
return nil;
}
let authorNickname: String? = cursor["author_nickname"];
let authorJid: BareJID? = cursor["author_jid"];
let encryption: MessageEncryption = MessageEncryption(rawValue: cursor["encryption"] ?? 0) ?? .none;
let encryptionFingerprint: String? = cursor["fingerprint"];
let error: String? = cursor["error"];
var preview: String? = cursor["preview"];
return ChatMessage(id: id, timestamp: timestamp, account: account, jid: jid, state: MessageState(rawValue: stateInt)!, message: message, authorNickname: authorNickname, authorJid: authorJid, encryption: encryption, encryptionFingerprint: encryptionFingerprint, preview: preview, error: error);
switch entryType {
case .message:
let message: String = cursor["data"]!;
return ChatMessage(id: id, timestamp: timestamp, account: account, jid: jid, state: MessageState(rawValue: stateInt)!, message: message, authorNickname: authorNickname, authorJid: authorJid, encryption: encryption, encryptionFingerprint: encryptionFingerprint, error: error);
case .attachment:
let url: String = cursor["data"]!;
let appendix = parseAttachmentAppendix(string: cursor["appendix"]);
return ChatAttachment(id: id, timestamp: timestamp, account: account, jid: jid, state: MessageState(rawValue: stateInt)!, url: url, authorNickname: authorNickname, authorJid: authorJid, encryption: encryption, encryptionFingerprint: encryptionFingerprint, appendix: appendix, error: error);
case .linkPreview:
let url: String = cursor["data"]!;
return ChatLinkPreview(id: id, timestamp: timestamp, account: account, jid: jid, state: MessageState(rawValue: stateInt)!, url: url, authorNickname: authorNickname, authorJid: authorJid, encryption: encryption, encryptionFingerprint: encryptionFingerprint, error: error)
}
}
fileprivate func parseAttachmentAppendix(string: String?) -> ChatAttachmentAppendix {
guard let data = string?.data(using: .utf8) else {
return ChatAttachmentAppendix();
}
return (try? JSONDecoder().decode(ChatAttachmentAppendix.self, from: data)) ?? ChatAttachmentAppendix();
}
}
@ -393,58 +480,9 @@ public enum MessageDirection: Int {
public enum ItemType:Int {
case message = 0
}
class ChatMessage: ChatViewItemProtocol {
let id: Int;
let timestamp: Date;
let account: BareJID;
let message: String;
let jid: BareJID;
let state: MessageState;
let authorNickname: String?;
let authorJid: BareJID?;
let preview: String?;
let error: String?;
let encryption: MessageEncryption;
let encryptionFingerprint: String?;
init(id: Int, timestamp: Date, account: BareJID, jid: BareJID, state: MessageState, message: String, authorNickname: String?, authorJid: BareJID?, encryption: MessageEncryption, encryptionFingerprint: String?, preview: String? = nil, error: String?) {
self.id = id;
self.timestamp = timestamp;
self.account = account;
self.message = message;
self.jid = jid;
self.state = state;
self.authorNickname = authorNickname;
self.authorJid = authorJid;
self.preview = preview;
self.encryption = encryption;
self.encryptionFingerprint = encryptionFingerprint;
self.error = error;
}
func isMergeable(with: ChatViewItemProtocol) -> Bool {
guard let item = with as? ChatMessage else {
return false;
}
return self.account == item.account && self.jid == item.jid && self.state.direction == item.state.direction && self.authorNickname == item.authorNickname && self.authorJid == item.authorJid && abs(self.timestamp.timeIntervalSince(item.timestamp)) < allowedTimeDiff() && self.encryption == item.encryption && self.encryptionFingerprint == item.encryptionFingerprint;
}
func allowedTimeDiff() -> TimeInterval {
switch /*Settings.messageGrouping.string() ??*/ "smart" {
case "none":
return -1.0;
case "always":
return 60.0 * 60.0 * 24.0;
default:
return 30.0;
}
}
case attachment = 1
@available(iOS 13, *)
case linkPreview = 2
}
class DeletedMessage: ChatViewItemProtocol {
@ -468,4 +506,8 @@ class DeletedMessage: ChatViewItemProtocol {
return false;
}
func copyText(withTimestamp: Bool, withSender: Bool) -> String? {
return nil;
}
}

View file

@ -30,7 +30,7 @@ class AvatarEventHandler: XmppServiceEventHandler {
switch event {
case let e as PresenceModule.ContactPresenceChanged:
NotificationCenter.default.post(name: XmppService.CONTACT_PRESENCE_CHANGED, object: e);
guard let photoId = e.presence.vcardTempPhoto, let from = e.presence.from?.bareJid, let to = e.presence.to?.bareJid else {
guard let photoId = e.presence.vcardTempPhoto, let from = e.presence.from?.bareJid, let to = e.presence.to?.bareJid, e.presence.findChild(name: "x", xmlns: "http://jabber.org/protocol/muc#user") == nil else {
return;
}
AvatarManager.instance.avatarHashChanged(for: from, on: to, type: .vcardTemp, hash: photoId);

View file

@ -92,7 +92,30 @@ class MessageEventHandler: XmppServiceEventHandler {
let timestamp = e.message.delay?.stamp ?? Date();
let state: MessageState = ((e.message.type ?? .chat) == .error) ? .incoming_error_unread : .incoming_unread;
DBChatHistoryStore.instance.appendItem(for: account, with: from.bareJid, state: state, type: .message, timestamp: timestamp, stanzaId: e.message.id, data: body!, chatState: e.message.chatState, errorCondition: e.message.errorCondition, errorMessage: e.message.errorText, encryption: encryption, encryptionFingerprint: fingerprint, completionHandler: nil);
var type: ItemType = .message;
if let oob = e.message.oob {
if oob == body! {
type = .attachment;
}
}
DBChatHistoryStore.instance.appendItem(for: account, with: from.bareJid, state: state, type: type, timestamp: timestamp, stanzaId: e.message.id, data: body!, chatState: e.message.chatState, errorCondition: e.message.errorCondition, errorMessage: e.message.errorText, encryption: encryption, encryptionFingerprint: fingerprint, completionHandler: nil);
if type == .message && !state.isError, #available(iOS 13.0, *) {
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue | NSTextCheckingResult.CheckingType.address.rawValue);
let matches = detector.matches(in: body!, range: NSMakeRange(0, body!.utf16.count));
matches.forEach { match in
if let url = match.url, let scheme = url.scheme, ["https", "http"].contains(scheme) {
DBChatHistoryStore.instance.appendItem(for: account, with: from.bareJid, state: state, type: .linkPreview, timestamp: timestamp, stanzaId: nil, data: url.absoluteString, chatState: e.message.chatState, errorCondition: e.message.errorCondition, errorMessage: e.message.errorText, encryption: encryption, encryptionFingerprint: fingerprint, 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: account, with: from.bareJid, state: state, type: .linkPreview, timestamp: timestamp, stanzaId: nil, data: mapUrl.absoluteString, chatState: e.message.chatState, errorCondition: e.message.errorCondition, errorMessage: e.message.errorText, encryption: encryption, encryptionFingerprint: fingerprint, completionHandler: nil);
}
}
}
case let e as MessageDeliveryReceiptsModule.ReceiptEvent:
guard let from = e.message.from?.bareJid, let account = e.sessionObject.userBareJid else {
return;
@ -139,11 +162,34 @@ class MessageEventHandler: XmppServiceEventHandler {
let jid = account == from.bareJid ? to.bareJid : from.bareJid;
let timestamp = e.message.delay?.stamp ?? Date();
let state: MessageState = calculateState(direction: account == from.bareJid ? .outgoing : .incoming, error: ((e.message.type ?? .chat) == .error), unread: /*!Settings.markMessageCarbonsAsRead.bool()*/ true);
DBChatHistoryStore.instance.appendItem(for: account, with: jid, state: state, type: .message, timestamp: timestamp, stanzaId: e.message.id, data: body!, errorCondition: e.message.errorCondition, errorMessage: e.message.errorText, encryption: encryption, encryptionFingerprint: fingerprint, completionHandler: { (msgId) in
var type: ItemType = .message;
if let oob = e.message.oob {
if oob == body! {
type = .attachment;
}
}
DBChatHistoryStore.instance.appendItem(for: account, with: jid, state: state, type: type, timestamp: timestamp, stanzaId: e.message.id, data: body!, errorCondition: e.message.errorCondition, errorMessage: e.message.errorText, encryption: encryption, encryptionFingerprint: fingerprint, completionHandler: { (msgId) in
if state.direction == .outgoing {
DBChatHistoryStore.instance.markAsRead(for: account, with: jid, before: timestamp);
}
});
if type == .message && !state.isError, #available(iOS 13.0, *) {
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue | NSTextCheckingResult.CheckingType.address.rawValue);
let matches = detector.matches(in: body!, range: NSMakeRange(0, body!.utf16.count));
matches.forEach { match in
if let url = match.url, let scheme = url.scheme, ["https", "http"].contains(scheme) {
DBChatHistoryStore.instance.appendItem(for: account, with: jid, state: state, type: .linkPreview, timestamp: timestamp, stanzaId: nil, data: url.absoluteString, chatState: e.message.chatState, errorCondition: e.message.errorCondition, errorMessage: e.message.errorText, encryption: encryption, encryptionFingerprint: fingerprint, 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: account, with: jid, state: state, type: .linkPreview, timestamp: timestamp, stanzaId: nil, data: mapUrl.absoluteString, errorCondition: e.message.errorCondition, errorMessage: e.message.errorText, encryption: encryption, encryptionFingerprint: fingerprint, completionHandler: nil);
}
}
}
case let e as MessageArchiveManagementModule.ArchivedMessageReceivedEvent:
guard let account = e.sessionObject.userBareJid, let from = e.message.from, let to = e.message.to else {
return;
@ -152,10 +198,33 @@ class MessageEventHandler: XmppServiceEventHandler {
guard body != nil else {
return;
}
var type: ItemType = .message;
if let oob = e.message.oob {
if oob == body {
type = .attachment;
}
}
let jid = account == from.bareJid ? to.bareJid : from.bareJid;
let timestamp = e.timestamp!;
let state: MessageState = calculateState(direction: account == from.bareJid ? .outgoing : .incoming, error: ((e.message.type ?? .chat) == .error), unread: false);
DBChatHistoryStore.instance.appendItem(for: account, with: jid, state: state, type: .message, timestamp: timestamp, stanzaId: e.message.id, data: body!, errorCondition: e.message.errorCondition, errorMessage: e.message.errorText, encryption: encryption, encryptionFingerprint: fingerprint, completionHandler: nil);
DBChatHistoryStore.instance.appendItem(for: account, with: jid, state: state, type: type, timestamp: timestamp, stanzaId: e.message.id, data: body!, errorCondition: e.message.errorCondition, errorMessage: e.message.errorText, encryption: encryption, encryptionFingerprint: fingerprint, completionHandler: nil);
if type == .message && !state.isError, #available(iOS 13.0, *) {
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue | NSTextCheckingResult.CheckingType.address.rawValue);
let matches = detector.matches(in: body!, range: NSMakeRange(0, body!.utf16.count));
matches.forEach { match in
if let url = match.url, let scheme = url.scheme, ["https", "http"].contains(scheme) {
DBChatHistoryStore.instance.appendItem(for: account, with: from.bareJid, state: state, type: .linkPreview, timestamp: timestamp, stanzaId: nil, data: url.absoluteString, errorCondition: e.message.errorCondition, errorMessage: e.message.errorText, encryption: encryption, encryptionFingerprint: fingerprint, 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: account, with: from.bareJid, state: state, type: .linkPreview, timestamp: timestamp, stanzaId: nil, data: mapUrl.absoluteString, errorCondition: e.message.errorCondition, errorMessage: e.message.errorText, encryption: encryption, encryptionFingerprint: fingerprint, completionHandler: nil);
}
}
}
case let e as OMEMOModule.AvailabilityChangedEvent:
NotificationCenter.default.post(name: MessageEventHandler.OMEMO_AVAILABILITY_CHANGED, object: e);
default:
@ -163,75 +232,105 @@ class MessageEventHandler: XmppServiceEventHandler {
}
}
static func sendMessage(chat: DBChat, body: String?, url: String?, encrypted: ChatEncryption? = nil, stanzaId: String? = nil) {
guard let msg = body ?? url else {
return;
}
let encryption = encrypted ?? chat.options.encryption ?? ChatEncryption(rawValue: Settings.messageEncryption.string()!)!;
let message = chat.createMessage(msg);
message.id = stanzaId ?? UUID().uuidString;
message.messageDelivery = .request;
let account = chat.account;
let jid = chat.jid.bareJid;
switch encryption {
case .omemo:
if stanzaId == nil {
let fingerprint = DBOMEMOStore.instance.identityFingerprint(forAccount: account, andAddress: SignalAddress(name: account.stringValue, deviceId: Int32(bitPattern: DBOMEMOStore.instance.localRegistrationId(forAccount: account)!)));
DBChatHistoryStore.instance.appendItem(for: account, with: jid, state: .outgoing_unsent, type: .message, timestamp: Date(), stanzaId: message.id, data: msg, encryption: .decrypted, encryptionFingerprint: fingerprint, completionHandler: nil);
static func sendAttachment(chat: DBChat, originalUrl: URL?, uploadedUrl: String, appendix: ChatAttachmentAppendix, completionHandler: (()->Void)?) {
self.sendMessage(chat: chat, body: nil, url: uploadedUrl, chatAttachmentAppendix: appendix, messageStored: { (msgId) in
DispatchQueue.main.async {
if originalUrl != nil {
_ = DownloadStore.instance.store(originalUrl!, filename: originalUrl!.lastPathComponent, with: "\(msgId)");
}
XmppService.instance.tasksQueue.schedule(for: jid, task: { (completionHandler) in
sendEncryptedMessage(message, from: account, completionHandler: { result in
switch result {
case .success(_):
DBChatHistoryStore.instance.updateItemState(for: account, with: jid, stanzaId: message.id!, from: .outgoing_unsent, to: .outgoing, withTimestamp: Date());
case .failure(let err):
let condition = (err is ErrorCondition) ? (err as? ErrorCondition) : nil;
guard condition == nil || condition! != .gone else {
completionHandler();
return;
}
completionHandler?();
}
})
}
var errorMessage: String? = nil;
if let encryptionError = err as? SignalError {
switch encryptionError {
case .noSession:
errorMessage = "There is no trusted device to send message to";
default:
errorMessage = "It was not possible to send encrypted message due to encryption error";
static func sendMessage(chat: DBChat, body: String?, url: String?, encrypted: ChatEncryption? = nil, stanzaId: String? = nil, chatAttachmentAppendix: ChatAttachmentAppendix? = nil, messageStored: ((Int)->Void)? = nil) {
guard let msg = body ?? url else {
return;
}
let encryption = encrypted ?? chat.options.encryption ?? ChatEncryption(rawValue: Settings.messageEncryption.string()!)!;
let message = chat.createMessage(msg);
message.id = stanzaId ?? UUID().uuidString;
message.messageDelivery = .request;
let account = chat.account;
let jid = chat.jid.bareJid;
switch encryption {
case .omemo:
if stanzaId == nil {
let fingerprint = DBOMEMOStore.instance.identityFingerprint(forAccount: account, andAddress: SignalAddress(name: account.stringValue, deviceId: Int32(bitPattern: DBOMEMOStore.instance.localRegistrationId(forAccount: account)!)));
DBChatHistoryStore.instance.appendItem(for: account, with: jid, state: .outgoing_unsent, type: url == nil ? .message : .attachment, timestamp: Date(), stanzaId: message.id, data: msg, encryption: .decrypted, encryptionFingerprint: fingerprint, chatAttachmentAppendix: chatAttachmentAppendix, completionHandler: messageStored);
}
XmppService.instance.tasksQueue.schedule(for: jid, task: { (completionHandler) in
sendEncryptedMessage(message, from: account, completionHandler: { result in
switch result {
case .success(_):
DBChatHistoryStore.instance.updateItemState(for: account, with: jid, stanzaId: message.id!, from: .outgoing_unsent, to: .outgoing, withTimestamp: Date());
case .failure(let err):
let condition = (err is ErrorCondition) ? (err as? ErrorCondition) : nil;
guard condition == nil || condition! != .gone else {
completionHandler();
return;
}
var errorMessage: String? = nil;
if let encryptionError = err as? SignalError {
switch encryptionError {
case .noSession:
errorMessage = "There is no trusted device to send message to";
default:
errorMessage = "It was not possible to send encrypted message due to encryption error";
}
}
DBChatHistoryStore.instance.markOutgoingAsError(for: account, with: jid, stanzaId: message.id!, errorCondition: .undefined_condition, errorMessage: errorMessage);
}
completionHandler();
});
});
case .none:
message.oob = url;
let type: ItemType = url == nil ? .message : .attachment;
if stanzaId == nil {
DBChatHistoryStore.instance.appendItem(for: account, with: jid, state: .outgoing_unsent, type: type, timestamp: Date(), stanzaId: message.id, data: msg, encryption: .none, encryptionFingerprint: nil, chatAttachmentAppendix: chatAttachmentAppendix, completionHandler: messageStored);
}
XmppService.instance.tasksQueue.schedule(for: jid, task: { (completionHandler) in
sendUnencryptedMessage(message, from: account, completionHandler: { result in
switch result {
case .success(_):
let timestamp = Date();
DBChatHistoryStore.instance.updateItemState(for: account, with: jid, stanzaId: message.id!, from: .outgoing_unsent, to: .outgoing, withTimestamp: timestamp);
if type == .message, #available(iOS 13.0, *) {
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue | NSTextCheckingResult.CheckingType.address.rawValue);
let matches = detector.matches(in: body!, range: NSMakeRange(0, body!.utf16.count));
matches.forEach { match in
if let url = match.url, let scheme = url.scheme, ["https", "http"].contains(scheme) {
DBChatHistoryStore.instance.appendItem(for: account, with: jid, state: .outgoing, type: .linkPreview, timestamp: timestamp, stanzaId: nil, data: url.absoluteString, encryption: .none, encryptionFingerprint: nil, 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: account, with: jid, state: .outgoing, type: .linkPreview, timestamp: timestamp, stanzaId: nil, data: mapUrl.absoluteString, encryption: .none, encryptionFingerprint: nil, completionHandler: nil);
}
}
DBChatHistoryStore.instance.markOutgoingAsError(for: account, with: jid, stanzaId: message.id!, errorCondition: .undefined_condition, errorMessage: errorMessage);
}
completionHandler();
});
});
case .none:
message.oob = url;
if stanzaId == nil {
DBChatHistoryStore.instance.appendItem(for: account, with: jid, state: .outgoing_unsent, type: .message, timestamp: Date(), stanzaId: message.id, data: msg, encryption: .none, encryptionFingerprint: nil, completionHandler: nil);
}
XmppService.instance.tasksQueue.schedule(for: jid, task: { (completionHandler) in
sendUnencryptedMessage(message, from: account, completionHandler: { result in
switch result {
case .success(_):
DBChatHistoryStore.instance.updateItemState(for: account, with: jid, stanzaId: message.id!, from: .outgoing_unsent, to: .outgoing, withTimestamp: Date());
case .failure(let err):
guard let condition = err as? ErrorCondition, condition != .gone else {
completionHandler();
return;
}
DBChatHistoryStore.instance.markOutgoingAsError(for: account, with: jid, stanzaId: message.id!, errorCondition: err as? ErrorCondition ?? .undefined_condition, errorMessage: "Could not send message");
case .failure(let err):
guard let condition = err as? ErrorCondition, condition != .gone else {
completionHandler();
return;
}
completionHandler();
});
DBChatHistoryStore.instance.markOutgoingAsError(for: account, with: jid, stanzaId: message.id!, errorCondition: err as? ErrorCondition ?? .undefined_condition, errorMessage: "Could not send message");
}
completionHandler();
});
}
});
}
}
fileprivate static func sendUnencryptedMessage(_ message: Message, from account: BareJID, completionHandler: @escaping (Result<Void,Error>)->Void) {
guard let client = XmppService.instance.getClient(for: account), client.state == .connected else {

View file

@ -78,7 +78,29 @@ class MucEventHandler: XmppServiceEventHandler {
let authorJid = e.nickname == nil ? nil : room.presences[e.nickname!]?.jid?.bareJid;
DBChatHistoryStore.instance.appendItem(for: room.account, with: room.roomJid, state: ((e.nickname == nil) || (room.nickname != e.nickname!)) ? .incoming_unread : .outgoing, authorNickname: e.nickname, authorJid: authorJid, type: .message, timestamp: e.timestamp, stanzaId: e.message.id, data: body, encryption: MessageEncryption.none, encryptionFingerprint: nil, completionHandler: nil);
var type: ItemType = .message;
if let oob = e.message.oob {
if oob == body {
type = .attachment;
}
}
DBChatHistoryStore.instance.appendItem(for: room.account, with: room.roomJid, state: ((e.nickname == nil) || (room.nickname != e.nickname!)) ? .incoming_unread : .outgoing, authorNickname: e.nickname, authorJid: authorJid, type: type, timestamp: e.timestamp, stanzaId: e.message.id, data: body, encryption: MessageEncryption.none, encryptionFingerprint: nil, completionHandler: nil);
if type == .message && e.message.type != StanzaType.error, #available(iOS 13.0, *) {
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue | NSTextCheckingResult.CheckingType.address.rawValue);
let matches = detector.matches(in: body, range: NSMakeRange(0, body.utf16.count));
matches.forEach { match in
if let url = match.url, let scheme = url.scheme, ["https", "http"].contains(scheme) {
DBChatHistoryStore.instance.appendItem(for: room.account, with: room.roomJid, state: ((e.nickname == nil) || (room.nickname != e.nickname!)) ? .incoming_unread : .outgoing, authorNickname: e.nickname, authorJid: authorJid, type: .linkPreview, timestamp: e.timestamp, stanzaId: nil, data: url.absoluteString, chatState: e.message.chatState, errorCondition: e.message.errorCondition, errorMessage: e.message.errorText, encryption: .none, encryptionFingerprint: nil, 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: room.account, with: room.roomJid, state: ((e.nickname == nil) || (room.nickname != e.nickname!)) ? .incoming_unread : .outgoing, authorNickname: e.nickname, authorJid: authorJid, type: .linkPreview, timestamp: e.timestamp, stanzaId: nil, data: mapUrl.absoluteString, chatState: e.message.chatState, errorCondition: e.message.errorCondition, errorMessage: e.message.errorText, encryption: .none, encryptionFingerprint: nil, completionHandler: nil);
}
}
}
case let e as MucModule.AbstractOccupantEvent:
NotificationCenter.default.post(name: MucEventHandler.ROOM_OCCUPANTS_CHANGED, object: e);
case let e as MucModule.PresenceErrorEvent:

View file

@ -66,7 +66,6 @@ open class XmppService: Logger, EventHandler {
}
if applicationState != .active {
AvatarManager.instance.clearCache();
ImageCache.shared.clearInMemoryCache();
}
}
}

View file

@ -23,11 +23,21 @@ import UIKit
class ChatSettingsViewController: CustomTableViewController {
let tree: [[SettingsEnum]] = [
[SettingsEnum.recentsMessageLinesNo, SettingsEnum.recentsSortType],
[SettingsEnum.sendMessageOnReturn, SettingsEnum.deleteChatHistoryOnClose, SettingsEnum.enableMessageCarbons, SettingsEnum.messageDeliveryReceipts, SettingsEnum.messageEncryption],
[SettingsEnum.sharingViaHttpUpload, SettingsEnum.simplifiedLinkToHTTPFile, SettingsEnum.maxImagePreviewSize, SettingsEnum.clearImagePreviewCache],
];
let tree: [[SettingsEnum]] = {
if #available(iOS 13.0, *) {
return [
[SettingsEnum.recentsMessageLinesNo, SettingsEnum.recentsSortType],
[SettingsEnum.sendMessageOnReturn, SettingsEnum.deleteChatHistoryOnClose, SettingsEnum.enableMessageCarbons, SettingsEnum.messageDeliveryReceipts, SettingsEnum.messageEncryption],
[SettingsEnum.sharingViaHttpUpload, SettingsEnum.linkPreviews, SettingsEnum.maxImagePreviewSize, SettingsEnum.clearDownloadStore],
];
} else {
return [
[SettingsEnum.recentsMessageLinesNo, SettingsEnum.recentsSortType],
[SettingsEnum.sendMessageOnReturn, SettingsEnum.deleteChatHistoryOnClose, SettingsEnum.enableMessageCarbons, SettingsEnum.messageDeliveryReceipts, SettingsEnum.messageEncryption],
[SettingsEnum.sharingViaHttpUpload, SettingsEnum.maxImagePreviewSize, SettingsEnum.clearDownloadStore],
];
}
}();
override func numberOfSections(in tableView: UITableView) -> Int {
return tree.count;
@ -106,11 +116,11 @@ class ChatSettingsViewController: CustomTableViewController {
return cell;
case .maxImagePreviewSize:
let cell = tableView.dequeueReusableCell(withIdentifier: "MaxImagePreviewSizeTableViewCell", for: indexPath);
(cell.contentView.subviews[1] as! UILabel).text = MaxImagePreviewSizeItem.description(of: Settings.MaxImagePreviewSize.getInt());
(cell.contentView.subviews[1] as! UILabel).text = AutoFileDownloadLimit.description(of: Settings.fileDownloadSizeLimit.getInt());
cell.accessoryType = .disclosureIndicator;
return cell;
case .clearImagePreviewCache:
return tableView.dequeueReusableCell(withIdentifier: "ClearImagePreviewTableViewCell", for: indexPath);
case .clearDownloadStore:
return tableView.dequeueReusableCell(withIdentifier: "ClearDownloadStoreTableViewCell", for: indexPath);
case .messageDeliveryReceipts:
let cell = tableView.dequeueReusableCell(withIdentifier: "MessageDeliveryReceiptsTableViewCell", for: indexPath) as! SwitchTableViewCell;
cell.switchView.isOn = Settings.MessageDeliveryReceiptsEnabled.getBool();
@ -118,12 +128,14 @@ class ChatSettingsViewController: CustomTableViewController {
Settings.MessageDeliveryReceiptsEnabled.setValue(switchView.isOn);
};
return cell;
case .simplifiedLinkToHTTPFile:
let cell = tableView.dequeueReusableCell(withIdentifier: "SimplifiedLinkToFileTableViewCell", for: indexPath) as! SwitchTableViewCell;
cell.switchView.isOn = Settings.SimplifiedLinkToFileIfPreviewIsAvailable.getBool();
case .linkPreviews:
let cell = tableView.dequeueReusableCell(withIdentifier: "LinkPreviewsTableViewCell", for: indexPath) as! SwitchTableViewCell;
if #available(iOS 13.0, *) {
cell.switchView.isOn = Settings.linkPreviews.getBool();
cell.valueChangedListener = {(switchView: UISwitch) in
Settings.SimplifiedLinkToFileIfPreviewIsAvailable.setValue(switchView.isOn);
Settings.linkPreviews.setValue(switchView.isOn);
};
}
return cell;
case .sendMessageOnReturn:
let cell = tableView.dequeueReusableCell(withIdentifier: "SendMessageOnReturnTableViewCell", for: indexPath) as! SwitchTableViewCell;
@ -162,34 +174,35 @@ class ChatSettingsViewController: CustomTableViewController {
case .maxImagePreviewSize:
let controller = TablePickerViewController(style: .grouped);
let values: [Int] = [0, 1, 2, 4, 8, 10, 15, 30, 50, Int.max];
controller.selected = values.firstIndex(of: Settings.MaxImagePreviewSize.getInt() ) ?? 0;
controller.selected = values.firstIndex(of: Settings.fileDownloadSizeLimit.getInt() ) ?? 0;
controller.items = values.map({ (it)->TablePickerViewItemsProtocol in
return MaxImagePreviewSizeItem(value: it);
return AutoFileDownloadLimit(value: it);
});
//controller.selected = 1;
controller.onSelectionChange = { (_item) -> Void in
let item = _item as! MaxImagePreviewSizeItem;
Settings.MaxImagePreviewSize.setValue(item.value);
let item = _item as! AutoFileDownloadLimit;
Settings.fileDownloadSizeLimit.setValue(item.value);
self.tableView.reloadData();
};
self.navigationController?.pushViewController(controller, animated: true);
case .clearImagePreviewCache:
let alert = UIAlertController(title: "Image cache", message: "We are using \(ImageCache.shared.diskCacheSize/(1024*1014)) MB of storage.", preferredStyle: .actionSheet);
case .clearDownloadStore:
let alert = UIAlertController(title: "Download storage", message: "We are using \(DownloadStore.instance.size/(1024*1014)) MB of storage.", preferredStyle: .actionSheet);
alert.addAction(UIAlertAction(title: "Flush", style: .destructive, handler: {(action) in
DispatchQueue.global(qos: .background).async {
ImageCache.shared.emptyDiskCache();
DownloadStore.instance.clear();
}
}));
alert.addAction(UIAlertAction(title: "Older than 7 days", style: .destructive, handler: {(action) in
DispatchQueue.global(qos: .background).async {
ImageCache.shared.emptyDiskCache(olderThan: Date().addingTimeInterval(7*24*60*60.0));
DownloadStore.instance.clear(olderThan: Date().addingTimeInterval(7*24*60*60.0*(-1.0)));
}
}));
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil));
alert.popoverPresentationController?.sourceView = self.tableView;
alert.popoverPresentationController?.sourceRect = self.tableView.rectForRow(at: indexPath);
self.present(alert, animated: true, completion: nil);
break;
case .messageEncryption:
let current = ChatEncryption(rawValue: Settings.messageEncryption.getString() ?? "") ?? .none;
let controller = TablePickerViewController(style: .grouped);
@ -218,14 +231,15 @@ class ChatSettingsViewController: CustomTableViewController {
case recentsSortType = 3
case sharingViaHttpUpload = 4
case maxImagePreviewSize = 5;
case clearImagePreviewCache = 6;
case clearDownloadStore = 6;
case messageDeliveryReceipts = 7;
case simplifiedLinkToHTTPFile = 8;
@available(iOS 13.0, *)
case linkPreviews = 8;
case sendMessageOnReturn = 9;
case messageEncryption = 10;
}
internal class MaxImagePreviewSizeItem: TablePickerViewItemsProtocol {
internal class AutoFileDownloadLimit: TablePickerViewItemsProtocol {
public static func description(of value: Int) -> String {
if value == Int.max {
@ -240,7 +254,7 @@ class ChatSettingsViewController: CustomTableViewController {
init(value: Int) {
self.value = value;
self.description = MaxImagePreviewSizeItem.description(of: value);
self.description = AutoFileDownloadLimit.description(of: value);
}
}

View file

@ -28,7 +28,7 @@ class ExperimentalSettingsViewController: CustomTableViewController {
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 5;
return 4;
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
@ -48,13 +48,6 @@ class ExperimentalSettingsViewController: CustomTableViewController {
Settings.enableBookmarksSync.setValue(switchView.isOn);
}
return cell;
case .enableNewUI:
let cell = tableView.dequeueReusableCell(withIdentifier: "EnableNewUITableViewCell", for: indexPath) as! SwitchTableViewCell;
cell.switchView.isOn = Settings.EnableNewUI.getBool();
cell.valueChangedListener = {(switchView: UISwitch) in
Settings.EnableNewUI.setValue(switchView.isOn);
}
return cell;
case .enableMarkdown:
let cell = tableView.dequeueReusableCell(withIdentifier: "EnableMarkdownTableViewCell", for: indexPath) as! SwitchTableViewCell;
cell.switchView.isOn = Settings.EnableMarkdownFormatting.getBool();
@ -84,8 +77,7 @@ class ExperimentalSettingsViewController: CustomTableViewController {
internal enum SettingsEnum: Int {
case notificationsFromUnknown = 0
case enableBookmarksSync = 1
case enableNewUI = 2
case enableMarkdown = 3
case showEmoticons = 4
case enableMarkdown = 2
case showEmoticons = 3
}
}

View file

@ -160,7 +160,6 @@ class SettingsViewController: CustomTableViewController {
return cell;
} else {
let cell = tableView.dequeueReusableCell(withIdentifier: "AboutSettingsViewCell", for: indexPath);
cell.accessoryType = .disclosureIndicator;
return cell;
}
}

View file

@ -0,0 +1,303 @@
//
// DownloadManager.swift
//
// Siskin IM
// 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. Look for COPYING file in the top folder.
// If not, see https://www.gnu.org/licenses/.
//
import Foundation
import MobileCoreServices
import TigaseSwift
class DownloadManager {
static let instance = DownloadManager();
private let dispatcher = QueueDispatcher(label: "download_manager_queue");
private var inProgress: [URL: Item] = [:];
private var itemDownloadInProgress: [Int] = [];
func downloadInProgress(for url: URL, completionHandler: @escaping (Result<String,DownloadError>)->Void) -> Bool {
return dispatcher.sync {
if let item = self.inProgress[url] {
item.onCompletion(completionHandler);
return true;
}
return false;
}
}
func downloadInProgress(for item: ChatAttachment) -> Bool {
return dispatcher.sync {
return self.itemDownloadInProgress.contains(item.id);
}
}
func download(item: ChatAttachment, maxSize: Int64) -> Bool {
return dispatcher.sync {
guard !itemDownloadInProgress.contains(item.id) else {
return false;
}
itemDownloadInProgress.append(item.id);
if let hash = Digest.sha1.digest(toHex: item.url.data(using: .utf8)!), var params = Settings.sharedDefaults!.dictionary(forKey: "upload-\(hash)"), let filename = params["name"] as? String {
var jids: [BareJID] = (params["jids"] as? [String])?.map({ BareJID($0) }) ?? [];
let sharedFileUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.siskinim.shared")!.appendingPathComponent("upload", isDirectory: true).appendingPathComponent(hash, isDirectory: false);
var handled = false;
if jids.contains(item.jid) {
jids = jids.filter({ (j) -> Bool in
return j != item.jid;
});
params["jids"] = jids.map({ $0.stringValue });
DownloadStore.instance.store(sharedFileUrl, filename: filename, with: "\(item.id)");
DBChatHistoryStore.instance.updateItem(for: item.account, with: item.jid, id: item.id, updateAppendix: { appendix in
appendix.filesize = params["size"] as? Int;
appendix.mimetype = params["mimeType"] as? String;
appendix.filename = filename;
appendix.state = .downloaded;
});
handled = true;
}
if jids.isEmpty || !FileManager.default.fileExists(atPath: sharedFileUrl.path) {
Settings.sharedDefaults?.removeObject(forKey: "upload-\(hash)")
if FileManager.default.fileExists(atPath: sharedFileUrl.path) {
try! FileManager.default.removeItem(at: sharedFileUrl);
}
} else {
Settings.sharedDefaults?.set(params, forKey: "upload-\(hash)");
}
guard !handled else {
self.itemDownloadInProgress = self.itemDownloadInProgress.filter({ (id) -> Bool in
return item.id != id;
});
return true;
}
}
let url = URL(string: item.url)!;
let sessionConfig = URLSessionConfiguration.default;
let session = URLSession(configuration: sessionConfig);
DownloadManager.retrieveHeaders(session: session, url: url, completionHandler: { headersResult in
switch headersResult {
case .success(let suggestedFilename, let expectedSize, let mimeType):
let isTooBig = expectedSize > maxSize;
DBChatHistoryStore.instance.updateItem(for: item.account, with: item.jid, id: item.id, updateAppendix: { appendix in
appendix.filesize = Int(expectedSize);
appendix.mimetype = mimeType;
appendix.filename = suggestedFilename;
if isTooBig {
appendix.state = .tooBig;
}
});
guard !isTooBig else {
self.dispatcher.async {
self.itemDownloadInProgress = self.itemDownloadInProgress.filter({ (id) -> Bool in
return item.id != id;
});
}
return;
}
DownloadManager.download(session: session, url: url, completionHandler: { result in
switch result {
case .success(let localUrl, let filename):
//let id = UUID().uuidString;
DownloadStore.instance.store(localUrl, filename: filename, with: "\(item.id)");
DBChatHistoryStore.instance.updateItem(for: item.account, with: item.jid, id: item.id, updateAppendix: { appendix in
appendix.state = .downloaded;
});
self.dispatcher.sync {
self.itemDownloadInProgress = self.itemDownloadInProgress.filter({ (id) -> Bool in
return item.id != id;
});
}
case .failure(let err):
var statusCode = 0;
switch err {
case .responseError(let code):
statusCode = code;
default:
break;
}
DBChatHistoryStore.instance.updateItem(for: item.account, with: item.jid, id: item.id, updateAppendix: { appendix in
appendix.state = statusCode == 404 ? .gone : .error;
});
self.dispatcher.sync {
self.itemDownloadInProgress = self.itemDownloadInProgress.filter({ (id) -> Bool in
return item.id != id;
});
}
}
});
break;
case .failure(let statusCode):
DBChatHistoryStore.instance.updateItem(for: item.account, with: item.jid, id: item.id, updateAppendix: { appendix in
appendix.state = statusCode == 404 ? .gone : .error;
});
self.dispatcher.async {
self.itemDownloadInProgress = self.itemDownloadInProgress.filter({ (id) -> Bool in
return item.id != id;
});
}
}
})
return true;
}
}
func downloadFile(destination: DownloadStore, as id: String, url: URL, maxSize: Int64, excludedMimetypes: [String], completionHandler: @escaping (Result<String,DownloadError>)->Void) {
dispatcher.async {
if let item = self.inProgress[url] {
item.onCompletion(completionHandler);
} else {
let item = Item();
item.onCompletion(completionHandler);
self.inProgress[url] = item;
self.downloadFile(url: url, maxSize: maxSize, excludedMimetypes: excludedMimetypes) { (result) in
self.dispatcher.async {
switch result {
case .success(let localUrl, let filename):
//let id = UUID().uuidString;
destination.store(localUrl, filename: filename, with: id);
item.completed(with: .success(id))
case .failure(let err):
item.completed(with: .failure(err));
}
}
}
}
}
}
func downloadFile(url: URL, maxSize: Int64, excludedMimetypes: [String], completionHandler: @escaping (Result<(URL,String),DownloadError>)->Void) {
let sessionConfig = URLSessionConfiguration.default;
let session = URLSession(configuration: sessionConfig);
DownloadManager.retrieveHeaders(session: session, url: url, completionHandler: { headersResult in
switch headersResult {
case .success(let suggestedFilename, let expectedSize, let mimeType):
if let type = mimeType {
guard !excludedMimetypes.contains(type) else {
completionHandler(.failure(.badMimeType(mimeType: type)));
return;
}
}
DownloadManager.download(session: session, url: url, completionHandler: completionHandler);
break;
case .failure(let statusCode):
completionHandler(.failure(.responseError(statusCode: statusCode)));
}
})
}
static func download(session: URLSession, url: URL, completionHandler: @escaping (Result<(URL,String), DownloadError>)->Void) {
let request = URLRequest(url: url);
let task = session.downloadTask(with: request) { (tempLocalUrl, response, error) in
if let tempLocalUrl = tempLocalUrl, error == nil {
if let filename = response?.suggestedFilename {
completionHandler(.success((tempLocalUrl, filename)));
} else if let mimeType = response?.mimeType, let filenameExt = DownloadManager.mimeTypeToExtension(mimeType: mimeType) {
completionHandler(.success((tempLocalUrl, "file.\(filenameExt)")));
} else if let uti = try? tempLocalUrl.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier, let filenameExt = UTTypeCopyPreferredTagWithClass(uti as CFString, kUTTagClassFilenameExtension)?.takeRetainedValue() as String? {
completionHandler(.success((tempLocalUrl, "file.\(filenameExt)")));
} else {
completionHandler(.success((tempLocalUrl, tempLocalUrl.lastPathComponent)));
}
} else {
guard error == nil else {
completionHandler(.failure(.networkError(error: error!)));
return;
}
completionHandler(.failure(.responseError(statusCode: (response as? HTTPURLResponse)?.statusCode ?? 500)));
}
}
task.resume();
}
static func mimeTypeToExtension(mimeType: String) -> String? {
let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil)
guard let fileUTI = uti?.takeRetainedValue(),
let fileExtension = UTTypeCopyPreferredTagWithClass(fileUTI, kUTTagClassFilenameExtension) else { return nil }
let extensionString = String(fileExtension.takeRetainedValue())
return extensionString
}
static func retrieveHeaders(session: URLSession, url: URL, completionHandler: @escaping (HeadersResult)->Void) {
var request = URLRequest(url: url);
request.httpMethod = "HEAD";
session.dataTask(with: request) { (data, resp, error) in
guard let response = resp as? HTTPURLResponse else {
completionHandler(.failure(statusCode: 500));
return;
}
switch response.statusCode {
case 200:
completionHandler(.success(suggestedFilename: response.suggestedFilename, expectedSize: response.expectedContentLength, mimeType: response.mimeType))
default:
completionHandler(.failure(statusCode: response.statusCode));
}
}.resume();
}
class Item {
let operationQueue = OperationQueue();
var result: Result<String,DownloadError>? = nil;
init() {
self.operationQueue.isSuspended = true;
}
func onCompletion(_ completionHandler: @escaping (Result<String,DownloadError>)->Void) {
operationQueue.addOperation {
completionHandler(self.result ?? .failure(DownloadError.responseError(statusCode: 500)));
}
}
func completed(with result: Result<String,DownloadError>?) {
self.result = result;
operationQueue.isSuspended = false;
}
}
enum HeadersResult {
case success(suggestedFilename: String?, expectedSize: Int64, mimeType: String?)
case failure(statusCode: Int)
}
enum DownloadError: Error {
case networkError(error: Error)
case responseError(statusCode: Int)
case tooBig(size: Int64, mimeType: String?, filename: String?)
case badMimeType(mimeType: String?)
}
}

View file

@ -0,0 +1,118 @@
//
// DownloadStore.swift
//
// Siskin IM
// 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. Look for COPYING file in the top folder.
// If not, see https://www.gnu.org/licenses/.
//
import Foundation
import UIKit
import TigaseSwift
class DownloadStore {
static let instance = DownloadStore();
fileprivate let queue = DispatchQueue(label: "download_store_queue");
let diskCacheUrl: URL;
//let cache = NSCache<NSString, NSImage>();
var size: Int {
guard let enumerator: FileManager.DirectoryEnumerator = try? FileManager.default.enumerator(at: diskCacheUrl, includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .isRegularFileKey]) else {
return 0;
}
return enumerator.map({ $0 as! URL}).filter({ (try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile ?? false}).map({ (try? $0.resourceValues(forKeys: [.totalFileAllocatedSizeKey]))?.totalFileAllocatedSize ?? 0}).reduce(0, +);
// for enu
// return (.map { url -> Int in
// return (try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize ?? 0) ?? 0;
// }.reduce(0, +)) ?? 0;
}
init() {
diskCacheUrl = try! FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("download", isDirectory: true);
if !FileManager.default.fileExists(atPath: diskCacheUrl.path) {
try! FileManager.default.createDirectory(at: diskCacheUrl, withIntermediateDirectories: true, attributes: nil);
}
NotificationCenter.default.addObserver(self, selector: #selector(messageRemoved(_:)), name: DBChatHistoryStore.MESSAGE_REMOVED, object: nil);
}
@objc func messageRemoved(_ notification: Notification) {
guard let item = notification.object as? DeletedMessage else {
return;
}
self.deleteFile(for: "\(item.id)")
}
func clear(olderThan: Date? = nil) {
guard let messageDirs = try? FileManager.default.contentsOfDirectory(at: diskCacheUrl, includingPropertiesForKeys: [.creationDateKey], options: .skipsSubdirectoryDescendants) else {
return;
}
for dir in messageDirs {
if olderThan == nil || (((try? dir.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date()) < olderThan!), let id = Int(dir.lastPathComponent) {
do {
try FileManager.default.removeItem(at: dir);
DBChatHistoryStore.instance.updateItem(id: id, updateAppendix: { appendix in
appendix.state = .removed;
});
} catch {}
}
}
}
func store(_ source: URL, filename: String, with id: String) -> URL {
let fileDir = diskCacheUrl.appendingPathComponent(id, isDirectory: true);
if !FileManager.default.fileExists(atPath: fileDir.path) {
try! FileManager.default.createDirectory(at: fileDir, withIntermediateDirectories: true, attributes: nil);
}
try? FileManager.default.copyItem(at: source, to: fileDir.appendingPathComponent(filename));
if !FileManager.default.fileExists(atPath: fileDir.appendingPathComponent(id).path) {
try! FileManager.default.createSymbolicLink(at: fileDir.appendingPathComponent(id), withDestinationURL: fileDir.appendingPathComponent(filename));
}
return fileDir.appendingPathComponent(filename);
}
func url(for id: String) -> URL? {
let linkUrl = diskCacheUrl.appendingPathComponent(id, isDirectory: true).appendingPathComponent(id);
guard let filePath = try? FileManager.default.destinationOfSymbolicLink(atPath: linkUrl.path) else {
print("link exists:", linkUrl.path, FileManager.default.fileExists(atPath: linkUrl.path));
return nil;
}
let filename = URL(fileURLWithPath: filePath).lastPathComponent;
// print("got link:", linkUrl, "path:", linkUrl.resolvingSymlinksInPath().path, "path to file:", filePath, "exists:", FileManager.default.fileExists(atPath: filePath), FileManager.default.fileExists(atPath: diskCacheUrl.appendingPathComponent(id, isDirectory: true).path));
let realPathToFile = diskCacheUrl.appendingPathComponent(id, isDirectory: true).appendingPathComponent(filename);
print("got link:", linkUrl, "path:", linkUrl.resolvingSymlinksInPath().path, "path to file:", realPathToFile.path, "exists:", FileManager.default.fileExists(atPath: realPathToFile.path), FileManager.default.fileExists(atPath: diskCacheUrl.appendingPathComponent(id, isDirectory: true).path));
return realPathToFile;
}
func deleteFile(for id: String) {
let fileDir = diskCacheUrl.appendingPathComponent(id, isDirectory: true);
guard FileManager.default.fileExists(atPath: fileDir.path) else {
return;
}
try? FileManager.default.removeItem(at: fileDir);
}
}

View file

@ -20,154 +20,83 @@
//
import UIKit
import Shared
import TigaseSwift
import MobileCoreServices
class ImageCache {
public static let DISK_CACHE_IMAGE_REMOVED = Notification.Name("DISK_CACHE.IMAGE_REMOVED");
static let shared = ImageCache();
fileprivate let diskCacheUrl: URL = {
let tmp = try! FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("images", isDirectory: true);
if !FileManager.default.fileExists(atPath: tmp.path) {
try? FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true, attributes: nil);
static func convertToAttachments() {
// converting ImageCache!!!
let diskCacheUrl = try! FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("images", isDirectory: true);
guard FileManager.default.fileExists(atPath: diskCacheUrl.path) else {
return;
}
return tmp;
}();
fileprivate let cache: NSCache<NSString,ImageHolder> = {
let tmp = NSCache<NSString,ImageHolder>();
tmp.countLimit = 20;
tmp.totalCostLimit = 20 * 1024 * 1024;
return tmp;
}();
var diskCacheSize: Int {
var size: Int = 0;
try? FileManager.default.contentsOfDirectory(at: diskCacheUrl, includingPropertiesForKeys: [.fileAllocatedSizeKey], options: .skipsSubdirectoryDescendants).forEach { (url) in
size = size + ((try? FileManager.default.attributesOfItem(atPath: url.path)[FileAttributeKey.size] as? Int) ?? 0)!;
}
return size;
}
func emptyDiskCache(olderThan: Date? = nil) {
try? FileManager.default.contentsOfDirectory(at: diskCacheUrl, includingPropertiesForKeys: nil, options: .skipsSubdirectoryDescendants).forEach({ (url) in
if olderThan != nil {
let creationDate = (try? FileManager.default.attributesOfItem(atPath: url.path))?[FileAttributeKey.creationDate] as? Date;
if (creationDate == nil) || (creationDate!.compare(olderThan!) == .orderedDescending) {
return;
}
}
try? FileManager.default.removeItem(at: url);
cache.removeObject(forKey: url.pathComponents.last! as NSString);
NotificationCenter.default.post(name: ImageCache.DISK_CACHE_IMAGE_REMOVED, object: self, userInfo: ["url": url]);
let previewsToConvert = try! DBConnection.main.prepareStatement("SELECT id FROM chat_history WHERE preview IS NOT NULL").query(map: { cursor -> Int in
return cursor["id"]!;
});
}
func clearInMemoryCache() {
cache.removeAllObjects();
}
func getURL(for key: String?) -> URL? {
guard key != nil else {
return nil;
}
let convertStmt: DBStatement = try! DBConnection.main.prepareStatement("SELECT id, account, jid, author_nickname, author_jid, timestamp, item_type, data, state, preview, encryption, fingerprint, error, appendix, preview, stanza_id FROM chat_history WHERE id = ?");
let removePreviewStmt: DBStatement = try! DBConnection.main.prepareStatement("UPDATE chat_history SET preview = NULL WHERE id = ?");
return diskCacheUrl.appendingPathComponent(key!);
}
func get(for key: String?, ifMissing: (()->Void)?) -> UIImage? {
guard key != nil else {
ifMissing?();
return nil;
}
let val = cache.object(forKey: key! as NSString);
if val?.beginContentAccess() ?? false {
defer {
val?.endContentAccess();
}
return val!.image;
}
let image = UIImage(contentsOfFile: diskCacheUrl.appendingPathComponent(key!).path);
if image == nil {
ifMissing?();
} else {
cache.setObject(ImageHolder(image: image!), forKey: key! as NSString);
}
return image;
}
func set(image value: UIImage, completion: ((String)->Void)? = nil) {
let key = "\(UUID().description).jpg";
cache.setObject(ImageHolder(image: value), forKey: key as NSString);
DispatchQueue.global(qos: .background).async {
if let data = value.jpegData(compressionQuality: 1.0) {
let newUrl = self.diskCacheUrl.appendingPathComponent(key);
_ = FileManager.default.createFile(atPath: newUrl.path, contents: data, attributes: nil);
completion?(key);
}
}
}
func set(url value: URL, mimeType: String? = nil, completion: ((String)->Void)? = nil) {
let uti = mimeType != nil ? UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType! as CFString, kUTTypeImage)?.takeRetainedValue() : nil;
let ext = uti != nil ? UTTypeCopyPreferredTagWithClass(uti! as CFString, kUTTagClassFilenameExtension)?.takeRetainedValue() : nil;
let key = ext == nil ? UUID().description : "\(UUID().description).\(ext!)";
let newUrl = diskCacheUrl.appendingPathComponent(key);
try? FileManager.default.copyItem(at: value, to: newUrl);
DispatchQueue.global(qos: .background).async {
if let image = UIImage(contentsOfFile: newUrl.path) {
self.cache.setObject(ImageHolder(image: image), forKey: key as NSString);
completion?(key);
} else {
print("no image from copied URL", value, newUrl);
}
}
}
fileprivate class ImageHolder: NSDiscardableContent {
var counter = 0;
var image: UIImage!;
fileprivate init?(data: NSData?) {
guard data != nil else {
return nil;
let group = DispatchGroup();
group.enter();
for id in previewsToConvert {
guard let (item, preview, stanzaId) = try! convertStmt.findFirst(id, map: { (cursor) -> (ChatMessage, String, String?)? in
let account: BareJID = cursor["account"]!;
let jid: BareJID = cursor["jid"]!;
let stanzaId: String? = cursor["stanza_id"];
guard let item = DBChatHistoryStore.instance.itemFrom(cursor: cursor, for: account, with: jid) as? ChatMessage, let preview: String = cursor["preview"] else {
return nil;
}
return (item, preview, stanzaId);
}) else {
return;
}
image = UIImage(data: data! as Data);
guard image != nil else {
return nil;
if preview.hasPrefix("preview:image:"), item.error != nil {
let url = diskCacheUrl.appendingPathComponent(String(preview.dropFirst(14)));
if FileManager.default.fileExists(atPath: url.path) {
group.enter();
var appendix = ChatAttachmentAppendix();
var filename = "image.jpg";
if let values = try? url.resourceValues(forKeys: [.typeIdentifierKey, .fileSizeKey]) {
if let uti = values.typeIdentifier {
appendix.mimetype = UTTypeCopyPreferredTagWithClass(uti as CFString, kUTTagClassMIMEType)?.takeRetainedValue() as String?;
if let ext = UTTypeCopyPreferredTagWithClass(kUTTagClassFilenameExtension, uti as CFString)?.takeRetainedValue() as String? {
filename = "image.\(ext)";
}
}
appendix.filesize = values.fileSize;
}
appendix.filename = filename;
let isAttachmentOnly = URL(string: item.message) != nil;
DBChatHistoryStore.instance.appendItem(for: item.account, with: item.jid, state: item.state, authorNickname: item.authorNickname, authorJid: item.authorJid, type: .attachment, timestamp: item.timestamp, stanzaId: stanzaId, data: item.message, encryption: item.encryption, encryptionFingerprint: item.encryptionFingerprint, chatAttachmentAppendix: appendix, skipItemAlreadyExists: true, completionHandler: { newId in
DownloadStore.instance.store(url, filename: filename, with: "\(newId)");
if isAttachmentOnly {
DBChatHistoryStore.instance.removeItem(for: item.account, with: item.jid, itemId: item.id);
} else {
try! removePreviewStmt.update(item.id);
}
try? FileManager.default.removeItem(at: url);
group.leave();
});
} else {
try! removePreviewStmt.update(item.id);
}
} else {
try! removePreviewStmt.update(item.id);
}
}
group.notify(queue: DispatchQueue.main, execute: {
try? FileManager.default.removeItem(at: diskCacheUrl);
})
fileprivate init(image: UIImage) {
self.image = image;
}
@objc fileprivate func discardContentIfPossible() {
if counter == 0 {
image = nil;
}
}
@objc fileprivate func isContentDiscarded() -> Bool {
return image == nil;
}
@objc fileprivate func beginContentAccess() -> Bool {
guard !isContentDiscarded() else {
return false;
}
counter += 1;
return true;
}
@objc fileprivate func endContentAccess() {
counter -= 1;
}
group.leave();
}
}

View file

@ -0,0 +1,102 @@
//
// MetadataCache.swift
//
// Siskin IM
// 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. Look for COPYING file in the top folder.
// If not, see https://www.gnu.org/licenses/.
//
import Foundation
import LinkPresentation
import TigaseSwift
@available(iOS 13.0, *)
class MetadataCache {
static let instance = MetadataCache();
private var cache: [URL: Result<LPLinkMetadata, MetadataCache.CacheError>] = [:];
private let diskCacheUrl: URL;
private let dispatcher = QueueDispatcher(label: "MetadataCache");
private var inProgress: [URL: OperationQueue] = [:];
init() {
diskCacheUrl = try! FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("metadata", isDirectory: true);
if !FileManager.default.fileExists(atPath: diskCacheUrl.path) {
try! FileManager.default.createDirectory(at: diskCacheUrl, withIntermediateDirectories: true, attributes: nil);
}
}
func store(_ value: LPLinkMetadata, for id: String) {
let fileUrl = diskCacheUrl.appendingPathComponent("\(id).metadata");
guard let codedData = try? NSKeyedArchiver.archivedData(withRootObject: value, requiringSecureCoding: false) else {
return;
}
try? codedData.write(to: fileUrl);
}
func metadata(for id: String) -> LPLinkMetadata? {
guard let data = FileManager.default.contents(atPath: diskCacheUrl.appendingPathComponent("\(id).metadata").path) else {
return nil;
}
return try! NSKeyedUnarchiver.unarchivedObject(ofClass: LPLinkMetadata.self, from: data);
}
func generateMetadata(for url: URL, withId id: String, completionHandler: @escaping (LPLinkMetadata?)->Void) {
dispatcher.async {
if let queue = self.inProgress[url] {
queue.addOperation {
completionHandler(self.metadata(for: id));
}
} else {
let queue = OperationQueue();
queue.isSuspended = true;
self.inProgress[url] = queue;
queue.addOperation {
completionHandler(self.metadata(for: id));
}
DispatchQueue.main.async {
let provider = LPMetadataProvider();
provider.startFetchingMetadata(for: url, completionHandler: { (meta, error) in
if let metadata = meta {
self.store(metadata, for: id);
} else {
print("failed to download metadata for:", url);
let metadata = LPLinkMetadata();
metadata.originalURL = url;
self.store(metadata, for: id);
}
self.dispatcher.async {
self.inProgress.removeValue(forKey: url);
queue.isSuspended = false;
}
})
}
}
}
}
enum CacheError: Error {
case NO_DATA
case RETRIEVAL_ERROR
}
}

View file

@ -38,42 +38,46 @@ public enum Settings: String {
case RecentsMessageLinesNo
case RecentsOrder
case SharingViaHttpUpload
case MaxImagePreviewSize
//case MaxImagePreviewSize
case fileDownloadSizeLimit
case MessageDeliveryReceiptsEnabled
case SimplifiedLinkToFileIfPreviewIsAvailable
//case SimplifiedLinkToFileIfPreviewIsAvailable
case SendMessageOnReturn
case CopyMessagesWithTimestamps
case XmppPipelining
case AppearanceTheme
case enableBookmarksSync
case messageEncryption
case EnableNewUI = "new-ui"
case EnableMarkdownFormatting = "markdown"
case ShowEmoticons
@available(iOS 13.0, *)
case linkPreviews
public static let SETTINGS_CHANGED = Notification.Name("settingsChanged");
fileprivate static var store: UserDefaults {
return UserDefaults.standard;
}
fileprivate static var sharedDefaults = UserDefaults(suiteName: "group.TigaseMessenger.Share");
public static let sharedDefaults = UserDefaults(suiteName: "group.TigaseMessenger.Share");
public static func initialize() {
let defaults: [String: AnyObject] = [
"DeleteChatHistoryOnChatClose" : false as AnyObject,
"enableMessageCarbons" : true as AnyObject,
"RosterType" : "flat" as AnyObject,
"RosterItemsOrder" : RosterSortingOrder.alphabetical.rawValue as AnyObject,
"RosterAvailableOnly" : false as AnyObject,
"RosterDisplayHiddenGroup" : false as AnyObject,
"AutoSubscribeOnAcceptedSubscriptionRequest" : false as AnyObject,
"NotificationsFromUnknown" : true as AnyObject,
"RecentsMessageLinesNo" : 2 as AnyObject,
"RecentsOrder" : "byTime" as AnyObject,
"SendMessageOnReturn" : true as AnyObject,
"AppearanceTheme": "classic" as AnyObject,
"messageEncryption": "none" as AnyObject
let defaults: [String: Any] = [
"DeleteChatHistoryOnChatClose" : false,
"enableMessageCarbons" : true,
"RosterType" : "flat",
"RosterItemsOrder" : RosterSortingOrder.alphabetical.rawValue,
"RosterAvailableOnly" : false,
"RosterDisplayHiddenGroup" : false,
"AutoSubscribeOnAcceptedSubscriptionRequest" : false,
"NotificationsFromUnknown" : true,
"RecentsMessageLinesNo" : 2,
"RecentsOrder" : "byTime",
"SendMessageOnReturn" : true,
"AppearanceTheme": "classic",
"messageEncryption": "none",
"linkPreviews": true
];
store.register(defaults: defaults);
["EnableMessageCarbons": Settings.enableMessageCarbons, "MessageEncryption": .messageEncryption, "EnableBookmarksSync": Settings.enableBookmarksSync].forEach { (oldKey, newKey) in
@ -82,6 +86,12 @@ public enum Settings: String {
store.set(val, forKey: newKey.rawValue)
}
}
if store.object(forKey: "MaxImagePreviewSize") != nil {
let downloadLimit = store.integer(forKey: "MaxImagePreviewSize");
store.removeObject(forKey: "MaxImagePreviewSize");
Settings.fileDownloadSizeLimit.setValue(downloadLimit);
}
store.removeObject(forKey: "new-ui");
store.dictionaryRepresentation().forEach { (k, v) in
if let key = Settings(rawValue: k) {
if isShared(key: key) {
@ -89,6 +99,29 @@ public enum Settings: String {
}
}
}
DispatchQueue.global(qos: .background).async {
let removeOlder = Date().addingTimeInterval(7 * 24 * 60 * 60 * (-1.0));
for (k,v) in self.sharedDefaults!.dictionaryRepresentation() {
if k.starts(with: "upload-") {
let hash = k.replacingOccurrences(of: "upload-", with: "");
if let timestamp = (v as? [String: Any])?["timestamp"] as? Date {
if timestamp < removeOlder {
self.sharedDefaults?.removeObject(forKey: k);
let localUploadDirUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.siskinim.shared")!.appendingPathComponent("upload", isDirectory: true).appendingPathComponent(hash, isDirectory: false);
if FileManager.default.fileExists(atPath: localUploadDirUrl.path) {
try? FileManager.default.removeItem(at: localUploadDirUrl);
}
}
} else {
self.sharedDefaults?.removeObject(forKey: k);
let localUploadDirUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.siskinim.shared")!.appendingPathComponent("upload", isDirectory: true).appendingPathComponent(hash, isDirectory: false);
if FileManager.default.fileExists(atPath: localUploadDirUrl.path) {
try? FileManager.default.removeItem(at: localUploadDirUrl);
}
}
}
}
}
}
public func setValue(_ value: String?) {
@ -133,6 +166,10 @@ public enum Settings: String {
return Settings.store.integer(forKey: self.rawValue);
}
public func integer() -> Int {
return getInt();
}
fileprivate static func valueChanged(forKey key: Settings, oldValue: Any?, newValue: Any?) {
var data: [AnyHashable:Any] = ["key": key.rawValue];
if oldValue != nil {
@ -148,7 +185,7 @@ public enum Settings: String {
}
fileprivate static func isShared(key: Settings) -> Bool {
return key == Settings.RosterDisplayHiddenGroup || key == Settings.SharingViaHttpUpload;
return key == Settings.RosterDisplayHiddenGroup || key == Settings.SharingViaHttpUpload || key == Settings.fileDownloadSizeLimit
}
}