TM-SGNL-iOS/SignalServiceKit/Contacts/TextAttachment.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

443 lines
16 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public struct UnsentTextAttachment {
public let body: StyleOnlyMessageBody?
public let textStyle: TextAttachment.TextStyle
public let textForegroundColor: UIColor
public let textBackgroundColor: UIColor?
public let background: TextAttachment.Background
public let linkPreviewDraft: OWSLinkPreviewDraft?
public var textContent: TextAttachment.TextContent {
return TextAttachment.textContent(body: body, textStyle: textStyle)
}
public init(
body: StyleOnlyMessageBody?,
textStyle: TextAttachment.TextStyle,
textForegroundColor: UIColor,
textBackgroundColor: UIColor?,
background: TextAttachment.Background,
linkPreviewDraft: OWSLinkPreviewDraft?
) {
self.body = body
self.textStyle = textStyle
self.textForegroundColor = textForegroundColor
self.textBackgroundColor = textBackgroundColor
self.background = background
self.linkPreviewDraft = linkPreviewDraft
}
public func validateAndPrepareForSending() throws -> ForSending {
let validatedLinkPreview: LinkPreviewDataSource?
if let linkPreview = linkPreviewDraft {
do {
validatedLinkPreview = try DependenciesBridge.shared.linkPreviewManager.buildDataSource(
from: linkPreview
)
} catch LinkPreviewError.featureDisabled {
validatedLinkPreview = .init(
metadata: .init(
urlString: linkPreview.urlString,
title: nil,
previewDescription: nil,
date: nil
),
imageDataSource: nil
)
} catch {
Logger.error("Failed to generate link preview.")
validatedLinkPreview = nil
}
} else {
validatedLinkPreview = nil
}
guard validatedLinkPreview != nil || !(body?.isEmpty ?? true) else {
throw OWSAssertionError("Empty content")
}
return .init(
body: self.body,
textStyle: self.textStyle,
textForegroundColor: self.textForegroundColor,
textBackgroundColor: self.textBackgroundColor,
background: self.background,
linkPreviewDraft: validatedLinkPreview
)
}
public struct ForSending {
public let body: StyleOnlyMessageBody?
public let textStyle: TextAttachment.TextStyle
public let textForegroundColor: UIColor
public let textBackgroundColor: UIColor?
public let background: TextAttachment.Background
public let linkPreviewDraft: LinkPreviewDataSource?
public var textContent: TextAttachment.TextContent {
return TextAttachment.textContent(body: body, textStyle: textStyle)
}
public func buildTextAttachment(
transaction: SDSAnyWriteTransaction
) -> OwnedAttachmentBuilder<TextAttachment>? {
var linkPreviewBuilder: OwnedAttachmentBuilder<OWSLinkPreview>?
if let linkPreview = linkPreviewDraft {
do {
linkPreviewBuilder = try DependenciesBridge.shared.linkPreviewManager.buildLinkPreview(
from: linkPreview,
tx: transaction.asV2Write
)
} catch LinkPreviewError.featureDisabled {
linkPreviewBuilder = .withoutFinalizer(OWSLinkPreview(urlString: linkPreview.metadata.urlString))
} catch {
Logger.error("Failed to generate link preview.")
}
}
guard linkPreviewBuilder != nil || !(body?.isEmpty ?? true) else {
owsFailDebug("Empty content")
return nil
}
func buildTextAttachment(linkPreview: OWSLinkPreview?) -> TextAttachment {
return TextAttachment(
body: body,
textStyle: textStyle,
textForegroundColor: textForegroundColor,
textBackgroundColor: textBackgroundColor,
background: background,
linkPreview: linkPreview
)
}
if let linkPreviewBuilder {
return linkPreviewBuilder.wrap(buildTextAttachment(linkPreview:))
} else {
return .withoutFinalizer(buildTextAttachment(linkPreview: nil))
}
}
}
}
public struct TextAttachment: Codable, Equatable {
private let body: StyleOnlyMessageBody?
private enum Constants {
static let maxGradientPoints = 100
}
public enum TextStyle: Int, Codable, Equatable {
case regular = 0
case bold = 1
case serif = 2
case script = 3
case condensed = 4
}
private let textStyle: TextStyle
public enum TextContent {
case empty
case styled(body: String, style: TextStyle)
case styledRanges(StyleOnlyMessageBody)
}
public private(set) var preview: OWSLinkPreview?
public var textContent: TextContent {
return Self.textContent(body: body, textStyle: textStyle)
}
fileprivate static func textContent(
body: StyleOnlyMessageBody?,
textStyle: TextStyle
) -> TextContent {
guard let body, body.isEmpty.negated else {
return .empty
}
switch textStyle {
case .regular:
if body.hasStyles {
return .styledRanges(body)
} else {
return .styled(body: body.text, style: .regular)
}
case .bold, .serif, .script, .condensed:
return .styled(body: body.text, style: textStyle)
}
}
private let textForegroundColorHex: UInt32?
public var textForegroundColor: UIColor? { textForegroundColorHex.map { UIColor(argbHex: $0) } }
private let textBackgroundColorHex: UInt32?
public var textBackgroundColor: UIColor? { textBackgroundColorHex.map { UIColor(argbHex: $0) } }
private enum RawBackground: Codable, Equatable {
case color(hex: UInt32)
case gradient(raw: RawGradient)
struct RawGradient: Codable, Equatable {
let colors: [UInt32]
let positions: [Float]
let angle: UInt32
init(colors: [UInt32], positions: [Float], angle: UInt32) {
self.colors = colors
self.positions = positions
self.angle = angle
}
init(from decoder: Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
self.colors = try container.decode([UInt32].self, forKey: .colors)
self.positions = try container.decode([Float].self, forKey: .positions)
self.angle = try container.decode(UInt32.self, forKey: .angle)
}
func buildProto() -> SSKProtoTextAttachmentGradient {
let builder = SSKProtoTextAttachmentGradient.builder()
if let startColor = colors.first {
builder.setStartColor(startColor)
}
if let endColor = colors.last {
builder.setEndColor(endColor)
}
builder.setColors(colors)
builder.setPositions(positions)
builder.setAngle(angle)
return builder.buildInfallibly()
}
}
}
private let rawBackground: RawBackground
public enum Background {
case color(UIColor)
case gradient(Gradient)
public struct Gradient {
public init(colors: [UIColor], locations: [CGFloat], angle: UInt32) {
self.colors = colors
self.locations = locations
self.angle = angle
}
public init(colors: [UIColor]) {
let locations: [CGFloat] = colors.enumerated().map { element in
return CGFloat(element.offset) / CGFloat(colors.count - 1)
}
self.init(colors: colors, locations: locations, angle: 180)
}
public let colors: [UIColor]
public let locations: [CGFloat]
public let angle: UInt32
}
}
public var background: Background {
switch rawBackground {
case .color(let hex):
return .color(.init(argbHex: hex))
case .gradient(let rawGradient):
return .gradient(.init(
colors: rawGradient.colors.map { UIColor(argbHex: $0) },
locations: rawGradient.positions.map { CGFloat($0) },
angle: rawGradient.angle
))
}
}
init(
from proto: SSKProtoTextAttachment,
bodyRanges: [SSKProtoBodyRange],
linkPreview: OWSLinkPreview?,
transaction: SDSAnyWriteTransaction
) throws {
self.body = proto.text?.nilIfEmpty.map { StyleOnlyMessageBody(text: $0, protos: bodyRanges) }
guard let style = proto.textStyle else {
throw OWSAssertionError("Missing style for attachment.")
}
switch style {
case .default, .regular:
self.textStyle = .regular
case .bold:
self.textStyle = .bold
case .serif:
self.textStyle = .serif
case .script:
self.textStyle = .script
case .condensed:
self.textStyle = .condensed
}
if proto.hasTextForegroundColor {
textForegroundColorHex = proto.textForegroundColor
} else {
textForegroundColorHex = nil
}
if proto.hasTextBackgroundColor {
textBackgroundColorHex = proto.textBackgroundColor
} else {
textBackgroundColorHex = nil
}
if let gradient = proto.gradient {
let colors: [UInt32]
let positions: [Float]
if !gradient.colors.isEmpty && !gradient.positions.isEmpty {
colors = Array(gradient.colors.prefix(Constants.maxGradientPoints))
positions = Array(gradient.positions.prefix(Constants.maxGradientPoints).map({ $0.isNaN ? 0 : $0 }))
} else {
colors = [ gradient.startColor, gradient.endColor ]
positions = [ 0, 1 ]
}
rawBackground = .gradient(raw: .init(
colors: colors,
positions: positions,
angle: gradient.angle
))
} else if proto.hasColor {
rawBackground = .color(hex: proto.color)
} else {
throw OWSAssertionError("Missing background for attachment.")
}
self.preview = linkPreview
}
public func buildProto(
parentStoryMessage: StoryMessage,
bodyRangeHandler: ([SSKProtoBodyRange]) -> Void,
transaction: SDSAnyReadTransaction
) throws -> SSKProtoTextAttachment {
let builder = SSKProtoTextAttachment.builder()
if let body {
builder.setText(body.text)
bodyRangeHandler(body.toProtoBodyRanges())
}
let textStyle: SSKProtoTextAttachmentStyle = {
switch self.textStyle {
case .regular: return .regular
case .bold: return .bold
case .serif: return .serif
case .script: return .script
case .condensed: return .condensed
}
}()
builder.setTextStyle(textStyle)
if let textForegroundColorHex = textForegroundColorHex {
builder.setTextForegroundColor(textForegroundColorHex)
}
if let textBackgroundColorHex = textBackgroundColorHex {
builder.setTextBackgroundColor(textBackgroundColorHex)
}
switch rawBackground {
case .color(let hex):
builder.setColor(hex)
case .gradient(let raw):
builder.setGradient(raw.buildProto())
}
if let preview = preview {
let previewProto = try DependenciesBridge.shared.linkPreviewManager.buildProtoForSending(
preview,
parentStoryMessage: parentStoryMessage,
tx: transaction.asV2Read
)
builder.setPreview(previewProto)
}
return try builder.build()
}
public init(
body: StyleOnlyMessageBody?,
textStyle: TextStyle,
textForegroundColor: UIColor,
textBackgroundColor: UIColor?,
background: Background,
linkPreview: OWSLinkPreview?
) {
self.body = body
self.textStyle = textStyle
self.textForegroundColorHex = textForegroundColor.argbHex
self.textBackgroundColorHex = textBackgroundColor?.argbHex
self.rawBackground = {
switch background {
case .color(let color):
return .color(hex: color.argbHex)
case .gradient(let gradient):
return .gradient(raw: .init(colors: gradient.colors.map { $0.argbHex },
positions: gradient.locations.map { Float($0) },
angle: gradient.angle))
}
}()
self.preview = linkPreview
}
/// Attempts to create a draft from the final version, so that it can be re-sent with new independent link attachment
/// objects created. If link recreation from url fails, will omit the link.
public func asUnsentAttachment() -> UnsentTextAttachment {
var linkPreviewDraft: OWSLinkPreviewDraft?
if
let preview = preview,
let urlString = preview.urlString,
let url = URL(string: urlString)
{
linkPreviewDraft = OWSLinkPreviewDraft(url: url, title: preview.title)
}
return UnsentTextAttachment(
body: body,
textStyle: textStyle,
textForegroundColor: textForegroundColor ?? .white,
textBackgroundColor: textBackgroundColor,
background: background,
linkPreviewDraft: linkPreviewDraft
)
}
public enum CodingKeys: String, CodingKey {
// Backwards compatibility; originally this held a vanilla string.
case body = "text"
case textStyle
case textForegroundColorHex
case textBackgroundColorHex
case rawBackground
case preview
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
do {
// Backwards compability; this used to contain just a raw string,
// which we now interpret as a style-less string.
if let rawText = try container.decodeIfPresent(String.self, forKey: .body) {
self.body = StyleOnlyMessageBody(plaintext: rawText)
} else {
self.body = nil
}
} catch {
self.body = try container.decodeIfPresent(StyleOnlyMessageBody.self, forKey: .body)
}
self.textStyle = try container.decode(TextStyle.self, forKey: .textStyle)
self.textForegroundColorHex = try container.decodeIfPresent(UInt32.self, forKey: .textForegroundColorHex)
self.textBackgroundColorHex = try container.decodeIfPresent(UInt32.self, forKey: .textBackgroundColorHex)
self.rawBackground = try container.decode(RawBackground.self, forKey: .rawBackground)
self.preview = try container.decodeIfPresent(OWSLinkPreview.self, forKey: .preview)
}
}