Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions NextcloudTalk.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@
"Chat upload/ChatFileUploader.swift",
"Chat upload/ChatFileUploadError.swift",
"Chat upload/ChatFileUploadMetadata.swift",
"Chat upload/ChatImageCompressor.swift",
"Chat views/NCChatTitleView.swift",
"Chat views/NCChatTitleView.xib",
"Chat views/NCMessageTextView.swift",
Expand Down Expand Up @@ -833,6 +834,7 @@
Extensions/IntExtension.swift,
Extensions/NSAttributedStringExtension.swift,
Extensions/UIFontExtension.swift,
Extensions/UIImageExtension.swift,
Extensions/UITableViewExtension.swift,
PlaceholderView.m,
PlaceholderView.xib,
Expand Down
6 changes: 6 additions & 0 deletions NextcloudTalk/Chat/Chat upload/ChatFileUpload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,10 @@ struct ChatFileUpload {

/// Reference id of the temporary message this upload belongs to, if there is one.
var referenceId: String?

/// Whether the other participants may modify the file, instead of only viewing it.
///
/// Only honoured with conversation subfolders enabled: the server keeps updatable files in a
/// separate subfolder, so this is a choice per upload and does not affect earlier ones.
var allowUpdate = false
}
16 changes: 12 additions & 4 deletions NextcloudTalk/Chat/Chat upload/ChatFileUploader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ enum ChatFileUploader {
var draftFolder: String?

if firstUpload.room.supportsConversationSubfolders {
// All uploads of a batch share the folder, so they share the permission of it as well
draftFolder = try await self.probeDraftFolder(for: firstUpload.room,
account: firstUpload.account,
fileNames: uploads.map { $0.fileName })
fileNames: uploads.map { $0.fileName },
allowUpdate: firstUpload.allowUpdate)
}

return await withTaskGroup(of: (index: Int, result: Result<Void, Error>).self) { group in
Expand Down Expand Up @@ -90,15 +92,18 @@ enum ChatFileUploader {
}
}

let draftFolder = try await self.probeDraftFolder(for: upload.room, account: upload.account, fileNames: [upload.fileName])
let draftFolder = try await self.probeDraftFolder(for: upload.room,
account: upload.account,
fileNames: [upload.fileName],
allowUpdate: upload.allowUpdate)

return try await self.draftFolderDestination(in: draftFolder, for: upload)
}

/// Makes sure the conversation subfolder exists and returns the draft folder to upload into.
private static func probeDraftFolder(for room: NCRoom, account: TalkAccount, fileNames: [String]) async throws -> String {
private static func probeDraftFolder(for room: NCRoom, account: TalkAccount, fileNames: [String], allowUpdate: Bool) async throws -> String {
do {
return try await NCAPIController.sharedInstance().probeConversationAttachmentFolder(inRoom: room.token, withFileNames: fileNames, forAccount: account).folder
return try await NCAPIController.sharedInstance().probeConversationAttachmentFolder(inRoom: room.token, withFileNames: fileNames, allowUpdate: allowUpdate, forAccount: account).folder
} catch {
throw ChatFileUploadError.destinationUnavailable(underlyingError: error)
}
Expand Down Expand Up @@ -184,8 +189,11 @@ enum ChatFileUploader {
fileName: upload.fileName,
referenceId: upload.referenceId,
talkMetaData: talkMetaData,
allowUpdate: upload.allowUpdate,
forAccount: upload.account)
case .attachmentFolder(let serverPath, _):
// The files sharing API has no way to grant update permissions, which is why the
// option is not offered at all without conversation subfolders.
try await apiController.shareFileOrFolder(forAccount: upload.account,
atPath: serverPath,
toRoom: upload.room.token,
Expand Down
141 changes: 141 additions & 0 deletions NextcloudTalk/Chat/Chat upload/ChatImageCompressor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
// SPDX-License-Identifier: GPL-3.0-or-later
//

import Foundation
import ImageIO
import UniformTypeIdentifiers

/// The quality an image is uploaded in, chosen by the user before sending.
enum ChatImageQuality {

/// Downscale and re-encode the image, which is the default.
case standard

/// Upload the image as it is, without touching it.
case original
}

/// Re-encodes images to a size that is reasonable to send into a conversation.
///
/// Kept deliberately close to the web client (max 1280 pixels, 80% quality) so the same image ends
/// up roughly the same size no matter where it was sent from. Unlike the web, which encodes WebP,
/// this produces JPEG: iOS can decode WebP but not encode it.
enum ChatImageCompressor {

/// Longest edge of a compressed image in pixels, which matches HD resolution.
static let maxPixelSize = 1280

/// Encoding quality of a compressed image.
static let compressionQuality = 0.8

/// File extensions that are images, but are not worth re-encoding: an animation would lose all
/// but its first frame and a vector would only lose its ability to scale.
private static let excludedFileExtensions = ["gif", "svg", "svgz"]

/// Whether an image of this type can be re-encoded.
///
/// Based on the file name instead of `ShareItem.isImage`, which is also set for files that only
/// have an image as their preview, like contacts.
static func supportsCompression(fileName: String) -> Bool {
let fileExtension = URL(fileURLWithPath: fileName).pathExtension.lowercased()

guard !fileExtension.isEmpty, !self.excludedFileExtensions.contains(fileExtension) else { return false }

return NCUtils.isImage(fileExtension: fileExtension)
}

/// Writes a downscaled copy of an image into `directory` and returns it.
///
/// The original file is never touched, so it stays available for another attempt when uploading
/// the compressed copy fails.
///
/// - Returns: The compressed copy and the name it should have in the conversation, or `nil` when
/// the original should be uploaded instead, because compressing it did not help.
nonisolated static func compressedCopy(of fileURL: URL, named fileName: String, in directory: URL) -> (url: URL, fileName: String)? {
guard let source = CGImageSourceCreateWithURL(fileURL as CFURL, nil),
CGImageSourceGetCount(source) > 0
else { return nil }

// Images that are already small enough are re-encoded as well, as an image can be heavy
// without being large. Whether that paid off is decided on the result further down.
let thumbnailOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
// Applies the orientation of the source, which the encoded copy does not carry anymore
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: self.maxPixelSize
]

guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary)
else { return nil }

let compressedFileName = (fileName as NSString).deletingPathExtension + ".jpg"
let compressedURL = self.uniqueURL(for: compressedFileName, in: directory)

guard let destination = CGImageDestinationCreateWithURL(compressedURL as CFURL, UTType.jpeg.identifier as CFString, 1, nil)
else { return nil }

CGImageDestinationSetProperties(destination, [kCGImageDestinationLossyCompressionQuality: self.compressionQuality] as CFDictionary)
CGImageDestinationAddImage(destination, image, nil)

guard CGImageDestinationFinalize(destination) else {
try? FileManager.default.removeItem(at: compressedURL)
return nil
}

// Re-encoding can make a file bigger, in which case the original is the better upload
guard self.fileSize(of: compressedURL) < self.fileSize(of: fileURL) else {
try? FileManager.default.removeItem(at: compressedURL)
return nil
}

return (compressedURL, compressedFileName)
}

/// Directory the compressed copies of one send operation are written to.
///
/// Every send gets a directory of its own, so that cleaning up after one can never take away
/// what another one is still uploading. Hand it to `removeTemporaryDirectory` when done.
static func temporaryDirectory() -> URL? {
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("upload-compressed", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)

do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
} catch {
NCLog.log("Could not create the directory for compressed images: \(error.localizedDescription)")
return nil
}

return directory
}

/// Throws away the compressed copies of a send that is over.
///
/// The originals are somewhere else, so an upload that failed can still be retried, which
/// compresses again into a directory of its own.
static func removeTemporaryDirectory(_ directory: URL) {
try? FileManager.default.removeItem(at: directory)
}

// MARK: - Utils

private static func uniqueURL(for fileName: String, in directory: URL) -> URL {
let fileURL = directory.appendingPathComponent(fileName)

guard FileManager.default.fileExists(atPath: fileURL.path) else { return fileURL }

let fileExtension = (fileName as NSString).pathExtension
let nameWithoutExtension = (fileName as NSString).deletingPathExtension

return directory.appendingPathComponent("\(nameWithoutExtension)-\(UUID().uuidString).\(fileExtension)")
}

private static func fileSize(of fileURL: URL) -> Int {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path) else { return 0 }

return attributes[.size] as? Int ?? 0
}
}
17 changes: 11 additions & 6 deletions NextcloudTalk/Network/NCAPIController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3227,6 +3227,7 @@ class NCAPIController: NSObject, NKCommonDelegate {

public func probeConversationAttachmentFolder(inRoom token: String,
withFileNames fileNames: [String],
allowUpdate: Bool = false,
forAccount account: TalkAccount,
completionBlock: @escaping (_ folder: String?, _ renames: [[String: String]]?, _ error: Error?) -> Void) {

Expand All @@ -3239,7 +3240,8 @@ class NCAPIController: NSObject, NKCommonDelegate {

let urlString = self.getRequestURL(forEndpoint: "chat/\(encodedToken)/attachment/folder", withAPIType: .chat, forAccount: account)
let parameters: [String: Any] = [
"fileNames": fileNames
"fileNames": fileNames,
"allowUpdate": allowUpdate
]

apiSessionManager.postOcs(urlString, account: account, parameters: parameters) { ocsResponse, ocsError in
Expand All @@ -3254,11 +3256,13 @@ class NCAPIController: NSObject, NKCommonDelegate {
}
}

// swiftlint:disable:next function_parameter_count
public func postConversationAttachment(inRoom token: String,
filePath: String,
fileName: String,
referenceId: String?,
talkMetaData: [String: Any]?,
allowUpdate: Bool = false,
forAccount account: TalkAccount,
completionBlock: @escaping (_ error: Error?) -> Void) {

Expand All @@ -3273,7 +3277,8 @@ class NCAPIController: NSObject, NKCommonDelegate {

var parameters: [String: Any] = [
"filePath": filePath,
"fileName": fileName
"fileName": fileName,
"allowUpdate": allowUpdate
]

// Required by API: missing referenceId results in a 400 response
Expand Down Expand Up @@ -3331,9 +3336,9 @@ class NCAPIController: NSObject, NKCommonDelegate {
}

@MainActor
public func probeConversationAttachmentFolder(inRoom token: String, withFileNames fileNames: [String], forAccount account: TalkAccount) async throws -> (folder: String, renames: [[String: String]]) {
public func probeConversationAttachmentFolder(inRoom token: String, withFileNames fileNames: [String], allowUpdate: Bool = false, forAccount account: TalkAccount) async throws -> (folder: String, renames: [[String: String]]) {
return try await withCheckedThrowingContinuation { continuation in
probeConversationAttachmentFolder(inRoom: token, withFileNames: fileNames, forAccount: account) { folder, renames, error in
probeConversationAttachmentFolder(inRoom: token, withFileNames: fileNames, allowUpdate: allowUpdate, forAccount: account) { folder, renames, error in
if let error {
continuation.resume(throwing: error)
} else if let folder {
Expand All @@ -3347,9 +3352,9 @@ class NCAPIController: NSObject, NKCommonDelegate {

@MainActor
// swiftlint:disable:next function_parameter_count
public func postConversationAttachment(inRoom token: String, filePath: String, fileName: String, referenceId: String?, talkMetaData: [String: Any]?, forAccount account: TalkAccount) async throws {
public func postConversationAttachment(inRoom token: String, filePath: String, fileName: String, referenceId: String?, talkMetaData: [String: Any]?, allowUpdate: Bool = false, forAccount account: TalkAccount) async throws {
return try await withCheckedThrowingContinuation { continuation in
postConversationAttachment(inRoom: token, filePath: filePath, fileName: fileName, referenceId: referenceId, talkMetaData: talkMetaData, forAccount: account) { error in
postConversationAttachment(inRoom: token, filePath: filePath, fileName: fileName, referenceId: referenceId, talkMetaData: talkMetaData, allowUpdate: allowUpdate, forAccount: account) { error in
if let error {
continuation.resume(throwing: error)
} else {
Expand Down
50 changes: 50 additions & 0 deletions NextcloudTalk/User Interface/Extensions/UIImageExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//

import CoreText
import UIKit

extension UIImage {
Expand Down Expand Up @@ -87,6 +88,55 @@ extension UIImage {
return resultImage
}

/// Draws a short text in a box, like the SD and HD badges of a video player.
///
/// Sized from the font of the label the badge is shown next to, so both scale together, and
/// returned as a template image, so it takes the color of whatever shows it.
///
/// - Parameter text: A word or an abbreviation. Anything longer only gets a wider box.
/// - Parameter font: The font of the label next to the badge.
static func badge(withText text: String, matching font: UIFont) -> UIImage {
let textFont = UIFont.systemFont(ofSize: font.pointSize * 0.7, weight: .bold)
let attributes: [NSAttributedString.Key: Any] = [.font: textFont, .foregroundColor: UIColor.black]

// The advance width of the text includes the side bearings of its letters, which are not
// the same on both sides and would put the letters off centre. The bounds of the glyphs
// themselves are what has to be padded and centred instead.
let line = CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: attributes))
let ink = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)

let borderWidth = max(1, (font.pointSize / 12).rounded())
// Enough room for the letters to not look cramped in their box, and it makes the badge as
// tall as the images of a menu row, which shows it without having to scale it
let padding = (font.pointSize / 3).rounded()

// The width follows the glyphs, the height the cap height of the font instead of the height
// of its line, which would add the room that ascenders and descenders need. Capitals use
// neither, so that room would only show up as more padding above and below them.
let size = CGSize(width: (ink.width + padding * 2).rounded(.up),
height: (textFont.capHeight + padding * 2).rounded(.up))

// Where the text has to be drawn for the glyphs to end up centred, which also spreads
// whatever the rounding of the size added evenly to both sides. Vertically the capitals are
// what gets centred, so every badge of the same font is equally tall.
let capTop = (size.height - textFont.capHeight) / 2
let origin = CGPoint(x: (size.width - ink.width) / 2 - ink.minX,
y: capTop + textFont.capHeight - textFont.ascender)

let image = UIGraphicsImageRenderer(size: size).image { _ in
let box = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size).insetBy(dx: borderWidth / 2, dy: borderWidth / 2),
cornerRadius: size.height * 0.25)
box.lineWidth = borderWidth
UIColor.black.setStroke()
box.stroke()

(text as NSString).draw(at: origin, withAttributes: attributes)
}

// Drawn in black, so the template can be tinted to any color
return image.withRenderingMode(.alwaysTemplate)
}

// Function to create a UIImage from a UILabel
@objc static func image(from label: UILabel) -> UIImage? {
// Begin a new image context with the size of the label
Expand Down
Loading
Loading