TM-SGNL-iOS/SignalServiceKit/Util/String+SSK.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

1010 lines
33 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import NaturalLanguage
extension NSString {
@objc
@available(swift, obsoleted: 1)
internal var ows_nilIfEmpty: NSString? {
(length == 0) ? nil : self
}
@objc
@available(swift, obsoleted: 1)
internal var filterStringForDisplay: NSString {
(self as String).filterStringForDisplay() as NSString
}
@objc
@available(swift, obsoleted: 1)
internal var filterFilename: NSString {
(self as String).filterFilename() as NSString
}
}
// MARK: -
public extension String {
var stripped: String {
ows_stripped()
}
var strippedOrNil: String? {
stripped.nilIfEmpty
}
var nilIfEmpty: String? {
isEmpty ? nil : self
}
var filterForDisplay: String {
filterStringForDisplay()
}
// There appears to be a bug in NSBigMutableString that causes a
// crash when using prefix to (not) truncate long strings to their
// current length. safePrefix() avoids this crash by only using
// prefix() if necessary.
func safePrefix(_ maxLength: Int) -> String {
guard maxLength < count else {
return self
}
return String(prefix(maxLength))
}
// Truncates string to be less than or equal to byteCount, while ensuring we never truncate partial characters for multibyte characters.
func truncated(toByteCount byteCount: UInt) -> String? {
var lowerBoundCharCount = 0
var upperBoundCharCount = self.count
while (lowerBoundCharCount < upperBoundCharCount) {
guard let upperBoundData = safePrefix(upperBoundCharCount).data(using: .utf8) else {
owsFailDebug("upperBoundData was unexpectedly nil")
return nil
}
if upperBoundData.count <= byteCount {
break
}
// converge
if upperBoundCharCount - lowerBoundCharCount == 1 {
upperBoundCharCount = lowerBoundCharCount
break
}
let midpointCharCount = (lowerBoundCharCount + upperBoundCharCount) / 2
let midpointString = safePrefix(midpointCharCount)
guard let midpointData = midpointString.data(using: .utf8) else {
owsFailDebug("midpointData was unexpectedly nil")
return nil
}
let midpointByteCount = midpointData.count
if midpointByteCount < byteCount {
lowerBoundCharCount = midpointCharCount
} else {
upperBoundCharCount = midpointCharCount
}
}
return String(safePrefix(upperBoundCharCount))
}
func replaceCharacters(
characterSet: CharacterSet,
replacement: String
) -> String {
let endIndex = self.endIndex
var startIndex = self.startIndex
// Build up a list of ranges that need to be replaced
var ranges = [Range<String.Index>]()
while startIndex < endIndex, let range = self.rangeOfCharacter(from: characterSet, options: [], range: startIndex..<endIndex) {
ranges.append(range)
startIndex = range.upperBound
}
// Don't do any allocation for unchanged strings
guard ranges.count > 0 else { return self }
// Create the result string and set up a capacity close to the final string
var result = ""
result.reserveCapacity(self.count)
// Iterate through the ranges, appending the string between the last
// match and the next, and then appending the replacement string
var currentIndex = self.startIndex
for range in ranges {
result += self[currentIndex..<range.lowerBound]
result += replacement
currentIndex = range.upperBound
}
// Add the remainder of the string
result += self[currentIndex..<endIndex]
return result
}
func removeCharacters(characterSet: CharacterSet) -> String {
return self.replaceCharacters(characterSet: characterSet, replacement: "")
}
}
// MARK: -
extension Optional where Wrapped == String {
public var isEmptyOrNil: Bool {
guard let value = self else {
return true
}
return value.isEmpty
}
}
public extension String {
/// A version of the string that only contains ASCII digits.
///
/// If you want to include non-ASCII digits, see `digitsOnly`.
///
/// ```
/// "1x2x3".digitsOnly
/// // => "123"
/// "123".digitsOnly
/// // => "23"
/// ```
var asciiDigitsOnly: String {
filter { $0.isASCII && $0.isNumber }
}
/// Is every character an ASCII digit between 0 and 9?
///
/// Note that this returns `true` for the empty string.
///
/// ```
/// "123".isAsciiDigitsOnly // => true
/// "1x23".isAsciiDigitsOnly // => false
/// "".isAsciiDigitsOnly // => true
/// "1.23".isAsciiDigitsOnly // => false
/// ```
var isAsciiDigitsOnly: Bool {
allSatisfy { $0.isASCII && $0.isNumber }
}
/// A version of the string that only contains ASCII alphanumerics.
///
/// ```swift
/// "abc123".asciiAlphanumericOnly
/// // => "abc23"
/// "ábc123".asciiAlphanumericOnly
/// // => "bc123"
/// ```
var asciiAlphanumericsOnly: String {
filter { $0.isASCII && ($0.isLetter || $0.isNumber) }
}
/// Is every character an ASCII alphanumeric?
///
/// Not that this returns `true` for the empty string.
///
/// ```swift
/// "aBc123".isAsciiAlphanumericsOnly // => true
/// "abc12#".isAsciiAlphanumericsOnly // => false
/// ```
var isAsciiAlphanumericsOnly: Bool {
allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber) }
}
func substring(withRange range: NSRange) -> String {
(self as NSString).substring(with: range)
}
func substring(beforeRange range: NSRange) -> String {
(self as NSString).substring(to: range.location)
}
func substring(afterRange range: NSRange) -> String {
(self as NSString).substring(from: range.location + range.length)
}
enum StringError: Error {
case invalidCharacterShift
}
/// Converts all non arabic numerals within a string to arabic numerals
///
/// For example: "Hello ١٢٣" would become "Hello 123"
var ensureArabicNumerals: String {
return String(map { character in
// Check if this character is a number between 0-9, if it's not just return it and carry on
//
// Some languages (like Chinese) have characters that represent larger numbers ( = 10^4)
// These are not easily translatable into arabic numerals at a character by character level,
// so we ignore them.
guard let number = character.wholeNumberValue, number <= 9, number >= 0 else { return character }
return Character("\(number)")
})
}
var entireRange: NSRange {
NSRange(location: 0, length: utf16.count)
}
init?(sysctlKey key: String) {
var size: Int = 0
sysctlbyname(key, nil, &size, nil, 0)
guard size > 0 else { return nil }
var value = [CChar](repeating: 0, count: size)
sysctlbyname(key, &value, &size, nil, 0)
self.init(cString: value)
}
func appendingPathComponent(_ other: String) -> String {
return (self as NSString).appendingPathComponent(other)
}
}
// MARK: -
@objc
public extension NSString {
var ensureArabicNumerals: String {
return (self as String).ensureArabicNumerals
}
class func stringFromSysctlKey(_ key: String) -> String? {
return String(sysctlKey: key)
}
var asAttributedString: NSAttributedString {
return NSAttributedString(string: self as String)
}
func asAttributedString(attributes: [NSAttributedString.Key: Any] = [:]) -> NSAttributedString {
NSAttributedString(string: self as String, attributes: attributes)
}
}
// MARK: - Attributed String Concatenation
public extension NSAttributedString {
var nilIfEmpty: NSAttributedString? {
isEmpty ? nil : self
}
var entireRange: NSRange {
NSRange(location: 0, length: string.utf16.count)
}
@objc
func stringByAppendingString(_ string: String, attributes: [NSAttributedString.Key: Any] = [:]) -> NSAttributedString {
return stringByAppendingString(NSAttributedString(string: string, attributes: attributes))
}
@objc
func stringByAppendingString(_ string: NSAttributedString) -> NSAttributedString {
let copy = mutableCopy() as! NSMutableAttributedString
copy.append(string)
return copy.copy() as! NSAttributedString
}
static func + (lhs: NSAttributedString, rhs: NSAttributedString) -> NSAttributedString {
return lhs.stringByAppendingString(rhs)
}
static func + (lhs: NSAttributedString, rhs: String) -> NSAttributedString {
return lhs.stringByAppendingString(rhs)
}
@objc
func ows_stripped() -> NSAttributedString {
guard length > 0 else { return self }
guard !string.ows_stripped().isEmpty else { return NSAttributedString() }
let mutableString = NSMutableAttributedString(attributedString: self)
mutableString.ows_strip()
return NSAttributedString(attributedString: mutableString)
}
@objc
var isEmpty: Bool {
length < 1
}
}
// MARK: -
@objc
public enum ImageAttachmentHeightReference: Int {
case pointSize
case lineHeight
func height(for font: UIFont) -> CGFloat {
switch self {
case .pointSize: return ceil(font.pointSize)
case .lineHeight: return ceil(font.lineHeight)
}
}
}
// MARK: -
public extension NSMutableAttributedString {
/// Set a default value for the given attribute. Preserves any existing ranges where the attribute
/// is already defined.
func addDefaultAttributeToEntireString(_ name: NSAttributedString.Key, value: Any) {
enumerateAttribute(name, in: entireRange) { existing, subrange, stop in
if existing == nil {
addAttribute(name, value: value, range: subrange)
}
}
}
func addAttributeToEntireString(_ name: NSAttributedString.Key, value: Any) {
addAttribute(name, value: value, range: entireRange)
}
@objc
func addAttributesToEntireString(_ attributes: [NSAttributedString.Key: Any] = [:]) {
addAttributes(attributes, range: entireRange)
}
func setAttributes(_ attributes: [NSAttributedString.Key: Any], forSubstring substring: String) {
guard !substring.isEmpty else {
owsFailDebug("Invalid substring.")
return
}
let str = string
guard let range = str.range(of: substring) else {
owsFailDebug("Substring not found.")
return
}
setAttributes(attributes, range: NSRange(range, in: str))
}
@objc
func append(_ string: String, attributes: [NSAttributedString.Key: Any] = [:]) {
append(NSAttributedString(string: string, attributes: attributes))
}
@objc(appendTemplatedImageNamed:font:)
func appendTemplatedImage(named imageName: String, font: UIFont) {
appendTemplatedImage(named: imageName, font: font, attributes: nil)
}
@objc(appendTemplatedImageNamed:font:heightReference:)
func appendTemplatedImage(named imageName: String, font: UIFont, heightReference: ImageAttachmentHeightReference) {
appendTemplatedImage(named: imageName, font: font, attributes: nil, heightReference: heightReference)
}
@objc(appendTemplatedImageNamed:font:attributes:)
func appendTemplatedImage(named imageName: String, font: UIFont, attributes: [NSAttributedString.Key: Any]?) {
appendTemplatedImage(named: imageName, font: font, attributes: attributes, heightReference: .pointSize)
}
@objc(appendTemplatedImageNamed:font:attributes:heightReference:)
func appendTemplatedImage(named imageName: String, font: UIFont, attributes: [NSAttributedString.Key: Any]?, heightReference: ImageAttachmentHeightReference) {
guard let image = UIImage(named: imageName) else {
return owsFailDebug("missing image named \(imageName)")
}
appendImage(image.withRenderingMode(.alwaysTemplate), font: font, attributes: attributes, heightReference: heightReference)
}
@objc(appendImageNamed:font:)
func appendImage(named imageName: String, font: UIFont) {
appendImage(named: imageName, font: font, attributes: nil)
}
@objc(appendImageNamed:font:heightReference:)
func appendImage(named imageName: String, font: UIFont, heightReference: ImageAttachmentHeightReference) {
appendImage(named: imageName, font: font, attributes: nil, heightReference: heightReference)
}
@objc(appendImageNamed:font:attributes:)
func appendImage(named imageName: String, font: UIFont, attributes: [NSAttributedString.Key: Any]?) {
appendImage(named: imageName, font: font, attributes: attributes, heightReference: .pointSize)
}
@objc(appendImageNamed:font:attributes:heightReference:)
func appendImage(named imageName: String, font: UIFont, attributes: [NSAttributedString.Key: Any]?, heightReference: ImageAttachmentHeightReference) {
guard let image = UIImage(named: imageName) else {
return owsFailDebug("missing image named \(imageName)")
}
appendImage(image, font: font, attributes: attributes, heightReference: heightReference)
}
@objc(appendImage:font:)
func appendImage(_ image: UIImage, font: UIFont) {
appendImage(image, font: font, attributes: nil)
}
@objc(appendImage:font:heightReference:)
func appendImage(_ image: UIImage, font: UIFont, heightReference: ImageAttachmentHeightReference) {
appendImage(image, font: font, attributes: nil, heightReference: heightReference)
}
@objc(appendImage:font:attributes:)
func appendImage(_ image: UIImage, font: UIFont, attributes: [NSAttributedString.Key: Any]?) {
appendImage(image, font: font, attributes: attributes, heightReference: .pointSize)
}
@objc(appendImage:font:attributes:heightReference:)
func appendImage(_ image: UIImage, font: UIFont, attributes: [NSAttributedString.Key: Any]?, heightReference: ImageAttachmentHeightReference) {
append(.with(image: image, font: font, attributes: attributes, heightReference: heightReference))
}
@objc
func ows_strip() {
guard length > 0 else { return }
let nsString = string as NSString
let strippedString = string.ows_stripped()
func replaceWithEmptyString() {
replaceCharacters(in: NSRange(location: 0, length: length), with: "")
}
guard !strippedString.isEmpty else { return replaceWithEmptyString() }
let remainingRange = nsString.range(of: strippedString)
guard remainingRange.location != NSNotFound else {
owsFailDebug("Unexpectedly missing substring after strip")
return replaceWithEmptyString()
}
let newEndOfString = remainingRange.location + remainingRange.length
if newEndOfString < mutableString.length {
mutableString.replaceCharacters(
in: NSRange(location: newEndOfString, length: mutableString.length - newEndOfString),
with: ""
)
}
let newStartOfString = remainingRange.location
if newStartOfString > 0 {
mutableString.replaceCharacters(
in: NSRange(location: 0, length: newStartOfString),
with: ""
)
}
}
}
public extension NSAttributedString {
/// Creates an `NSAttributedString` of the image as an `NSTextAttachment`,
/// vertically-centered in the relative font.
/// - Parameters:
/// - image: The image to use as the attachment.
/// - font: The font to use for sizing the image.
/// - attributes: A dictionary containing the attributes to add.
/// - centeringFont: The relative font to vertically center the image in.
/// If attaching this image to another attributed string of a different
/// font, specify that font here.
/// - heightReference: Which property of the font the image should be
/// sized relative to.
/// - Returns: An `NSAttributedString` with the specified image as an attachment, applying `attributes`, centered vertically in `centeringFont`, sized based on the `heightReference` property of `font`.
static func with(
image: UIImage,
font: UIFont,
attributes: [NSAttributedString.Key: Any]? = nil,
centerVerticallyRelativeTo centeringFont: UIFont? = nil,
heightReference: ImageAttachmentHeightReference = .lineHeight
) -> NSAttributedString {
let attachment = NSTextAttachment()
attachment.image = image
let centeringFont = centeringFont ?? font
// Match the image's height to the font's height while preserving
// the image's aspect ratio, and vertically center.
let imageHeight = heightReference.height(for: font)
let imageWidth = (imageHeight / image.size.height) * image.size.width
attachment.bounds = CGRect(
x: 0,
y: (centeringFont.capHeight - imageHeight) / 2,
width: imageWidth,
height: imageHeight
)
let attachmentString = NSAttributedString(attachment: attachment)
if let attributes = attributes {
let mutableString = NSMutableAttributedString(attributedString: attachmentString)
mutableString.addAttributes(attributes, range: mutableString.entireRange)
return mutableString
} else {
return attachmentString
}
}
}
// MARK: - Natural Text Alignment
public extension String {
private var dominantLanguage: String? {
return NLLanguageRecognizer.dominantLanguage(for: self)?.rawValue
}
/// The natural text alignment of a given string. This may be different
/// than the natural alignment of the current system locale depending on
/// the language of the string, especially for user entered text.
var naturalTextAlignment: NSTextAlignment {
guard let dominantLanguage = dominantLanguage else {
// If we can't identify the strings language, use the system language's natural alignment
return .natural
}
switch NSParagraphStyle.defaultWritingDirection(forLanguage: dominantLanguage) {
case .leftToRight:
return .left
case .rightToLeft:
return .right
case .natural:
return .natural
@unknown default:
return .natural
}
}
}
public extension NSString {
/// The natural text alignment of a given string. This may be different
/// than the natural alignment of the current system locale depending on
/// the language of the string, especially for user entered text.
@objc
var naturalTextAlignment: NSTextAlignment {
return (self as String).naturalTextAlignment
}
}
// MARK: - Selector Encoding
private let selectorOffset: UInt32 = 17
public extension String {
func caesar(shift: UInt32) throws -> String {
let shiftedScalars: [UnicodeScalar] = try unicodeScalars.map { c in
guard let shiftedScalar = UnicodeScalar((c.value + shift) % 127) else {
owsFailDebug("invalidCharacterShift")
throw StringError.invalidCharacterShift
}
return shiftedScalar
}
return String(String.UnicodeScalarView(shiftedScalars))
}
var encodedForSelector: String? {
guard let shifted = try? self.caesar(shift: selectorOffset) else {
owsFailDebug("shifted was unexpectedly nil")
return nil
}
guard let data = shifted.data(using: .utf8) else {
owsFailDebug("data was unexpectedly nil")
return nil
}
return data.base64EncodedString()
}
var decodedForSelector: String? {
guard let data = Data(base64Encoded: self) else {
owsFailDebug("data was unexpectedly nil")
return nil
}
guard let shifted = String(data: data, encoding: .utf8) else {
owsFailDebug("shifted was unexpectedly nil")
return nil
}
return try? shifted.caesar(shift: 127 - selectorOffset)
}
}
public extension NSString {
@objc
var encodedForSelector: String? {
return (self as String).encodedForSelector
}
@objc
var decodedForSelector: String? {
return (self as String).decodedForSelector
}
}
// MARK: - Emoji
extension UnicodeScalar {
class EmojiRange {
// rangeStart and rangeEnd are inclusive.
let rangeStart: UInt32
let rangeEnd: UInt32
// MARK: Initializers
init(rangeStart: UInt32, rangeEnd: UInt32) {
self.rangeStart = rangeStart
self.rangeEnd = rangeEnd
}
}
// From:
// https://www.unicode.org/Public/emoji/
// Current Version:
// https://www.unicode.org/Public/emoji/6.0/emoji-data.txt
//
// These ranges can be code-generated using:
//
// * Scripts/emoji-data.txt
// * Scripts/emoji_ranges.py
static let kEmojiRanges = [
// NOTE: Don't treat Pound Sign # as Jumbomoji.
// EmojiRange(rangeStart:0x23, rangeEnd:0x23),
// NOTE: Don't treat Asterisk * as Jumbomoji.
// EmojiRange(rangeStart:0x2A, rangeEnd:0x2A),
// NOTE: Don't treat Digits 0..9 as Jumbomoji.
// EmojiRange(rangeStart:0x30, rangeEnd:0x39),
// NOTE: Don't treat Copyright Symbol © as Jumbomoji.
// EmojiRange(rangeStart:0xA9, rangeEnd:0xA9),
// NOTE: Don't treat Trademark Sign ® as Jumbomoji.
// EmojiRange(rangeStart:0xAE, rangeEnd:0xAE),
EmojiRange(rangeStart: 0x200D, rangeEnd: 0x200D),
EmojiRange(rangeStart: 0x203C, rangeEnd: 0x203C),
EmojiRange(rangeStart: 0x2049, rangeEnd: 0x2049),
EmojiRange(rangeStart: 0x20D0, rangeEnd: 0x20FF),
EmojiRange(rangeStart: 0x2122, rangeEnd: 0x2122),
EmojiRange(rangeStart: 0x2139, rangeEnd: 0x2139),
EmojiRange(rangeStart: 0x2194, rangeEnd: 0x2199),
EmojiRange(rangeStart: 0x21A9, rangeEnd: 0x21AA),
EmojiRange(rangeStart: 0x231A, rangeEnd: 0x231B),
EmojiRange(rangeStart: 0x2328, rangeEnd: 0x2328),
EmojiRange(rangeStart: 0x2388, rangeEnd: 0x2388),
EmojiRange(rangeStart: 0x23CF, rangeEnd: 0x23CF),
EmojiRange(rangeStart: 0x23E9, rangeEnd: 0x23F3),
EmojiRange(rangeStart: 0x23F8, rangeEnd: 0x23FA),
EmojiRange(rangeStart: 0x24C2, rangeEnd: 0x24C2),
EmojiRange(rangeStart: 0x25AA, rangeEnd: 0x25AB),
EmojiRange(rangeStart: 0x25B6, rangeEnd: 0x25B6),
EmojiRange(rangeStart: 0x25C0, rangeEnd: 0x25C0),
EmojiRange(rangeStart: 0x25FB, rangeEnd: 0x25FE),
EmojiRange(rangeStart: 0x2600, rangeEnd: 0x27BF),
EmojiRange(rangeStart: 0x2934, rangeEnd: 0x2935),
EmojiRange(rangeStart: 0x2B05, rangeEnd: 0x2B07),
EmojiRange(rangeStart: 0x2B1B, rangeEnd: 0x2B1C),
EmojiRange(rangeStart: 0x2B50, rangeEnd: 0x2B50),
EmojiRange(rangeStart: 0x2B55, rangeEnd: 0x2B55),
EmojiRange(rangeStart: 0x3030, rangeEnd: 0x3030),
EmojiRange(rangeStart: 0x303D, rangeEnd: 0x303D),
EmojiRange(rangeStart: 0x3297, rangeEnd: 0x3297),
EmojiRange(rangeStart: 0x3299, rangeEnd: 0x3299),
EmojiRange(rangeStart: 0xFE00, rangeEnd: 0xFE0F),
EmojiRange(rangeStart: 0x1F000, rangeEnd: 0x1F0FF),
EmojiRange(rangeStart: 0x1F10D, rangeEnd: 0x1F10F),
EmojiRange(rangeStart: 0x1F12F, rangeEnd: 0x1F12F),
EmojiRange(rangeStart: 0x1F16C, rangeEnd: 0x1F171),
EmojiRange(rangeStart: 0x1F17E, rangeEnd: 0x1F17F),
EmojiRange(rangeStart: 0x1F18E, rangeEnd: 0x1F18E),
EmojiRange(rangeStart: 0x1F191, rangeEnd: 0x1F19A),
EmojiRange(rangeStart: 0x1F1AD, rangeEnd: 0x1F1FF),
EmojiRange(rangeStart: 0x1F201, rangeEnd: 0x1F20F),
EmojiRange(rangeStart: 0x1F21A, rangeEnd: 0x1F21A),
EmojiRange(rangeStart: 0x1F22F, rangeEnd: 0x1F22F),
EmojiRange(rangeStart: 0x1F232, rangeEnd: 0x1F23A),
EmojiRange(rangeStart: 0x1F23C, rangeEnd: 0x1F23F),
EmojiRange(rangeStart: 0x1F249, rangeEnd: 0x1F64F),
EmojiRange(rangeStart: 0x1F680, rangeEnd: 0x1F6FF),
EmojiRange(rangeStart: 0x1F774, rangeEnd: 0x1F77F),
EmojiRange(rangeStart: 0x1F7D5, rangeEnd: 0x1F7FF),
EmojiRange(rangeStart: 0x1F80C, rangeEnd: 0x1F80F),
EmojiRange(rangeStart: 0x1F848, rangeEnd: 0x1F84F),
EmojiRange(rangeStart: 0x1F85A, rangeEnd: 0x1F85F),
EmojiRange(rangeStart: 0x1F888, rangeEnd: 0x1F88F),
EmojiRange(rangeStart: 0x1F8AE, rangeEnd: 0x1FFFD),
EmojiRange(rangeStart: 0xE0020, rangeEnd: 0xE007F)
]
var isEmoji: Bool {
// Binary search.
var left: Int = 0
var right = Int(UnicodeScalar.kEmojiRanges.count - 1)
while true {
let mid = (left + right) / 2
let midRange = UnicodeScalar.kEmojiRanges[mid]
if value < midRange.rangeStart {
if mid == left {
return false
}
right = mid - 1
} else if value > midRange.rangeEnd {
if mid == right {
return false
}
left = mid + 1
} else {
return true
}
}
}
var isZeroWidthJoiner: Bool {
return value == 8205
}
}
extension String.UnicodeScalarView {
public func containsOnlyEmoji() -> Bool {
if self.isEmpty {
return false
}
if self.contains(where: { !$0.isEmoji && !$0.isZeroWidthJoiner }) {
return false
}
return true
}
public func containsOnlyEmojiIgnoringWhitespace() -> Bool {
if self.isEmpty {
return false
}
return self.allSatisfy { $0.isEmoji || $0.isZeroWidthJoiner || $0.properties.isWhitespace }
}
}
public extension String {
var glyphCount: Int {
// String.count reflects the number of user-visible characters
// the will be rendered by UILabel, UITextView, etc.
count
}
var isSingleEmoji: Bool {
glyphCount == 1 && containsEmoji
}
var containsEmoji: Bool {
unicodeScalars.contains { $0.isEmoji }
}
var containsOnlyEmoji: Bool {
return self.unicodeScalars.containsOnlyEmoji()
}
var containsOnlyEmojiIgnoringWhitespace: Bool {
self.unicodeScalars.containsOnlyEmojiIgnoringWhitespace()
}
func trimmedIfNeeded(maxGlyphCount: Int) -> String? {
// This is O(maxGlyphCount) instead of O(self.count).
if self.dropFirst(maxGlyphCount).isEmpty {
return nil
}
return String(self.prefix(maxGlyphCount))
}
func trimToGlyphCount(_ maxGlyphCount: Int) -> String {
return self.trimmedIfNeeded(maxGlyphCount: maxGlyphCount) ?? self
}
func trimmedIfNeeded(maxByteCount: Int) -> String? {
var utf8Count = 0
for index in self.indices {
utf8Count += self[index].utf8.count
if utf8Count > maxByteCount {
return String(self[..<index])
}
}
return nil
}
func trimToUtf8ByteCount(_ maxByteCount: Int) -> String {
return self.trimmedIfNeeded(maxByteCount: maxByteCount) ?? self
}
}
@objc
public extension NSString {
var glyphCount: Int {
return (self as String).glyphCount
}
var isSingleEmoji: Bool {
return (self as String).isSingleEmoji
}
var containsEmoji: Bool {
return (self as String).containsEmoji
}
var containsOnlyEmoji: Bool {
return (self as String).containsOnlyEmoji
}
func trimToUtf8ByteCount(_ maxByteCount: Int) -> String {
return (self as String).trimToUtf8ByteCount(maxByteCount)
}
}
// MARK: - encodeURIComponent
public extension String {
var encodeURIComponent: String? {
return (self as NSString).encodeURIComponent
}
}
@objc
public extension NSString {
var encodeURIComponent: String? {
// Match behavior of encodeURIComponent used by desktop.
//
// Removes any "/" in the base64. All other base64 chars are URL safe.
// Apple's built-in `stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URL*]]`
// doesn't offer a flavor for encoding "/".
var characterSet = CharacterSet.alphanumerics
characterSet.insert(charactersIn: "-_.!~*'()")
return addingPercentEncoding(withAllowedCharacters: characterSet)
}
}
// MARK: - Percent encoding
public extension String {
var percentEncodedAsUrlPath: String {
var components = URLComponents()
components.path = self
return components.percentEncodedPath
}
}
// MARK: -
public extension String {
static func formatDurationLossless(durationSeconds: UInt32) -> String {
NSString.formatDurationLossless(durationSeconds: durationSeconds)
}
static func formatDurationLossless(durationMs: UInt64) -> String {
NSString.formatDurationLossless(durationMs: durationMs)
}
}
// MARK: -
@objc
public extension NSString {
static func formatDurationLossless(durationSeconds: UInt32) -> String {
return formatDurationLossless(durationMs: UInt64(durationSeconds) * 1000)
}
static func formatDurationLossless(durationMs: UInt64) -> String {
let mSecondsPerSecond: UInt64 = 1000
let mSecondsPerMinute: UInt64 = mSecondsPerSecond * 60
let mSecondsPerHour: UInt64 = mSecondsPerMinute * 60
let mSecondsPerDay: UInt64 = mSecondsPerHour * 24
let mSecondsPerWeek: UInt64 = mSecondsPerDay * 7
let mSecondsPerYear: UInt64 = mSecondsPerDay * 365
let dateComponents: DateComponents = {
var dateComponents = DateComponents()
var remainingDuration = durationMs
let years = remainingDuration / mSecondsPerYear
remainingDuration -= years * mSecondsPerYear
dateComponents.year = Int(years)
let weeks = remainingDuration / mSecondsPerWeek
remainingDuration -= weeks * mSecondsPerWeek
dateComponents.weekOfYear = Int(weeks)
let days = remainingDuration / mSecondsPerDay
remainingDuration -= days * mSecondsPerDay
dateComponents.day = Int(days)
let minutes = remainingDuration / mSecondsPerMinute
remainingDuration -= minutes * mSecondsPerMinute
dateComponents.minute = Int(minutes)
let seconds = remainingDuration / mSecondsPerSecond
remainingDuration -= seconds * mSecondsPerSecond
dateComponents.second = Int(seconds)
return dateComponents
}()
let durationFormatter = DateComponentsFormatter()
durationFormatter.unitsStyle = .full
durationFormatter.allowedUnits = [.year, .weekOfMonth, .day, .hour, .minute, .second]
guard let formattedDuration = durationFormatter.string(from: dateComponents) else {
owsFailDebug("Could not format duration")
return ""
}
return formattedDuration
}
}
// MARK: - Filename
public extension String {
private static let permissibleFilenameCharRegex: NSRegularExpression = {
let pattern = "^[a-zA-Z0-9\\-_]+$"
do {
return try NSRegularExpression(pattern: pattern)
} catch let error {
owsFail("Failed to create filename char regex: \(error)")
}
}()
var isPermissibleAsFilename: Bool {
Self.permissibleFilenameCharRegex.hasMatch(input: self)
}
}
// MARK: - Phone Numbers
public extension String {
/// A pattern for quickly deciding if a string looks like an e164. It makes
/// no attempt at determining whether or not a particular sequence of digits
/// could ever be a dialable phone number.
private static let validE164StructureRegex = try! NSRegularExpression(
pattern: #"^\+[1-9][0-9]{0,18}$"#,
options: []
)
/// Checks if the value starts with a "+" and has [1, 19] digits.
var isStructurallyValidE164: Bool { Self.validE164StructureRegex.hasMatch(input: self) }
var filteredAsE164: String {
let maxLength = 256
let length = min(maxLength, count)
var result: [unichar] = []
result.reserveCapacity(length)
for uch in utf16 {
if (0x30...0x39).contains(uch) || (result.isEmpty && uch == 0x2B) {
result.append(uch)
if result.count >= length {
break
}
}
}
return String(utf16CodeUnits: result, count: result.count)
}
}
public extension NSString {
/// Checks if the value starts with a "+" and has [1, 19] digits.
@objc
var isStructurallyValidE164: Bool { (self as String).isStructurallyValidE164 }
}
// MARK: - StrippedNonEmptyString
/// A String that's been fed through `strippedOrNil` and isn't `nil`.
public struct StrippedNonEmptyString: Equatable {
public let rawValue: String
public init?(rawValue: String) {
guard let strippedValue = rawValue.strippedOrNil else {
return nil
}
self.rawValue = strippedValue
}
}