import Foundation // tonitodo: It makes more sense for this to live in the app. Can we move out of WMF? public struct ArticleAsLivingDocViewModel { public let nextRvStartId: UInt? public let sha: String? public let sections: [SectionHeader] public let articleInsertHtmlSnippets: [String] public let lastUpdatedTimestamp: String? public let summaryText: String? private let isoDateFormatter = ISO8601DateFormatter() public init(nextRvStartId: UInt?, sha: String?, sections: [SectionHeader], summaryText: String?, articleInsertHtmlSnippets: [String], lastUpdatedTimestamp: String?) { self.nextRvStartId = nextRvStartId self.sha = sha self.sections = sections self.summaryText = summaryText self.articleInsertHtmlSnippets = articleInsertHtmlSnippets self.lastUpdatedTimestamp = lastUpdatedTimestamp } public init?(significantEvents: SignificantEvents, traitCollection: UITraitCollection, theme: Theme) { guard let dayMonthNumberYearDateFormatter = DateFormatter.wmf_monthNameDayOfMonthNumberYear() else { assertionFailure("Unable to generate date formatters for Significant Events View Models") return nil } self.nextRvStartId = significantEvents.nextRvStartId self.sha = significantEvents.sha // initialize summary text var summaryText: String? = nil if let earliestDate = isoDateFormatter.date(from: significantEvents.summary.earliestTimestampString) { let currentDate = Date() let calendar = NSCalendar.current let unitFlags:Set = [.day] let components = calendar.dateComponents(unitFlags, from: earliestDate, to: currentDate) if let numberOfDays = components.day { summaryText = String.localizedStringWithFormat(CommonStrings.articleAsLivingDocSummaryTitle, significantEvents.summary.numChanges, significantEvents.summary.numUsers, numberOfDays) } } self.summaryText = summaryText // loop through typed events, turn into view models and segment off into sections var currentSectionEvents: [TypedEvent] = [] var sections: [SectionHeader] = [] var maybeCurrentTimestamp: Date? var maybePreviousTimestamp: Date? for originalEvent in significantEvents.typedEvents { var maybeEvent: TypedEvent? = nil if let smallEventViewModel = Event.Small(typedEvents: [originalEvent]) { maybeEvent = .small(smallEventViewModel) } else if let largeEventViewModel = Event.Large(typedEvent: originalEvent) { // this is just an optimization to have collection view height calculations sooner so it doesn't happen while the user is scrolling largeEventViewModel.calculateSideScrollingCollectionViewHeightForTraitCollection(traitCollection, theme: theme) maybeEvent = .large(largeEventViewModel) } guard let event = maybeEvent else { assertionFailure("Unable to instantiate event view model, skipping event") continue } switch originalEvent { case .large(let largeChange): maybeCurrentTimestamp = isoDateFormatter.date(from: largeChange.timestampString) case .newTalkPageTopic(let newTalkPageTopic): maybeCurrentTimestamp = isoDateFormatter.date(from: newTalkPageTopic.timestampString) case .vandalismRevert(let vandalismRevert): maybeCurrentTimestamp = isoDateFormatter.date(from: vandalismRevert.timestampString) case .small(let smallChange): maybeCurrentTimestamp = isoDateFormatter.date(from: smallChange.timestampString) } guard let currentTimestamp = maybeCurrentTimestamp else { assertionFailure("Significant Events - Unable to determine event timestamp, skipping event.") continue } if let previousTimestamp = maybePreviousTimestamp { let calendar = NSCalendar.current if !calendar.isDate(previousTimestamp, inSameDayAs: currentTimestamp) { // multiple days have passed since last event, package up current sections into new section let section = SectionHeader(timestamp: previousTimestamp, typedEvents: currentSectionEvents, subtitleDateFormatter: dayMonthNumberYearDateFormatter) sections.append(section) currentSectionEvents.removeAll() currentSectionEvents.append(event) maybePreviousTimestamp = currentTimestamp } else { currentSectionEvents.append(event) maybePreviousTimestamp = currentTimestamp } } else { currentSectionEvents.append(event) maybePreviousTimestamp = currentTimestamp } } // capture any final currentSectionEvents into new section if let currentTimestamp = maybeCurrentTimestamp { let section = SectionHeader(timestamp: currentTimestamp, typedEvents: currentSectionEvents, subtitleDateFormatter: dayMonthNumberYearDateFormatter) sections.append(section) currentSectionEvents.removeAll() } // collapse sibling small event view models var finalSections: [SectionHeader] = [] for section in sections { var collapsedEventViewModels: [TypedEvent] = [] var currentSmallChanges: [SignificantEvents.Event.Small] = [] for event in section.typedEvents { switch event { case .small(let smallEventViewModel): currentSmallChanges.append(contentsOf: smallEventViewModel.smallChanges) default: if currentSmallChanges.count > 0 { collapsedEventViewModels.append(.small(Event.Small(smallChanges: currentSmallChanges))) currentSmallChanges.removeAll() } collapsedEventViewModels.append(event) continue } } // add any final small changes if currentSmallChanges.count > 0 { collapsedEventViewModels.append(.small(Event.Small(smallChanges: currentSmallChanges))) currentSmallChanges.removeAll() } let collapsedSection = SectionHeader(timestamp: section.timestamp, typedEvents: collapsedEventViewModels, subtitleDateFormatter: dayMonthNumberYearDateFormatter) finalSections.append(collapsedSection) } finalSections = ArticleAsLivingDocViewModel.collapseSmallEvents(from: finalSections) self.sections = finalSections // grab first 3 large event html snippets var articleInsertHtmlSnippets: [String] = [] var lastUpdatedTimestamp: String? let htmlSnippetCountMax = 3 outerLoop: for (sectionIndex, section) in finalSections.enumerated() { for (itemIndex, event) in section.typedEvents.enumerated() { switch event { case .small(let smallEvent): if lastUpdatedTimestamp == nil { lastUpdatedTimestamp = smallEvent.timestampForDisplay() } case .large(let largeEvent): if lastUpdatedTimestamp == nil { lastUpdatedTimestamp = largeEvent.fullyRelativeTimestampForDisplay() } let indexPath = IndexPath(item: itemIndex, section: sectionIndex) if let htmlSnippet = largeEvent.articleInsertHtmlSnippet(isFirst: articleInsertHtmlSnippets.count == 0, isLast: articleInsertHtmlSnippets.count == htmlSnippetCountMax - 1, indexPath: indexPath) { if articleInsertHtmlSnippets.count < htmlSnippetCountMax { articleInsertHtmlSnippets.append(htmlSnippet) } else { if lastUpdatedTimestamp != nil { break outerLoop } } } } } } self.articleInsertHtmlSnippets = articleInsertHtmlSnippets self.lastUpdatedTimestamp = lastUpdatedTimestamp } /// Collapses sequential sections that contain only small events into one section, including a date range that represents the collected events static func collapseSmallEvents(from sections: [SectionHeader]) -> [SectionHeader] { guard let dayMonthNumberYearDateFormatter = DateFormatter.wmf_monthNameDayOfMonthNumberYear(), let isoDateFormatter = DateFormatter.wmf_iso8601() else { return sections } let enumeratedSections = sections.enumerated() var mutatedSections: [SectionHeader] = [] var rangesToCollapse: [ClosedRange] = [] for (outerIndex, outerSection) in enumeratedSections { let startIndex = outerIndex var endIndex = outerIndex if outerSection.containsOnlySmallEvents { for (innerIndex, innerSection) in enumeratedSections { guard innerIndex >= startIndex + 1, !rangesToCollapse.contains(where: {$0.contains(innerIndex) }) else { continue } if innerSection.containsOnlySmallEvents { endIndex = innerIndex } else { break } } } if startIndex != endIndex { // This range is eligible to be collapsed rangesToCollapse.append(startIndex...endIndex) } } var typedEvents: [TypedEvent] = [] typealias CollapsedSection = (section: SectionHeader, sectionHashes: [Int]) var collapsedSections: [CollapsedSection] = [] var collapsedSectionHashes: [Int] = [] // Create new sections for each collapsed range for range in rangesToCollapse { for sectionElement in sections[range] { typedEvents.append(contentsOf: sectionElement.typedEvents) } collapsedSectionHashes.append(contentsOf: sections[range].compactMap { $0.hashValue }) if let startIndex = range.first { let smallChanges = typedEvents.flatMap { $0.smallChanges } let collapsedSmallEvent = Event.Small(smallChanges: smallChanges) let smallTypedEvent = TypedEvent.small(collapsedSmallEvent) let smallChangeDates = smallChanges.compactMap { isoDateFormatter.date(from: $0.timestampString) } var dateRange: DateInterval? if let minDate = smallChangeDates.min(), let maxDate = smallChangeDates.max() { dateRange = DateInterval(start: minDate, end: maxDate) } let section = SectionHeader(timestamp: sections[startIndex].timestamp, typedEvents: [smallTypedEvent], subtitleDateFormatter: dayMonthNumberYearDateFormatter, dateRange: dateRange) collapsedSections.append((section, collapsedSectionHashes)) } typedEvents = [] collapsedSectionHashes = [] } var newlyCollapsedSectionHashes: [Int] = [] // Returns the small event collapsed section that represents the `sectionHash`, if one exists func firstCollapsedSectionContaining(sectionHash: Int) -> CollapsedSection? { return collapsedSections .first { collapsedSection in collapsedSection.sectionHashes.contains(sectionHash) } } // Reconstruct sections with newly eligible small event sections collapsed in proper order for section in sections { if let collapsedSection = firstCollapsedSectionContaining(sectionHash: section.hashValue), !newlyCollapsedSectionHashes.contains(section.hashValue) { mutatedSections.append(collapsedSection.section) newlyCollapsedSectionHashes.append(contentsOf: collapsedSection.sectionHashes) } if !newlyCollapsedSectionHashes.contains(section.hashValue) { mutatedSections.append(section) } } return mutatedSections } static func eventDisplayTimestamp(timestampString: String) -> String? { let isoDateFormatter = ISO8601DateFormatter() guard let shortFormatter = DateFormatter.wmf_24hshortTimeWithUTCTimeZone(), let date = isoDateFormatter.date(from: timestampString) else { return nil } let calendar = NSCalendar.current let components = calendar.dateComponents([.hour, .minute], from: date, to: Date()) if let hours = components.hour, let minutes = components.minute { switch hours { case ..<1: return String.localizedStringWithFormat(WMFLocalizedDateFormatStrings.minutesAgo(), minutes) case ..<24: return String.localizedStringWithFormat(WMFLocalizedDateFormatStrings.hoursAgo(), hours) default: break } } return shortFormatter.string(from: date) } static func displayTimestamp(timestampString: String, fullyRelative: Bool) -> String? { if let isoDateFormatter = DateFormatter.wmf_iso8601(), let timeDateFormatter = DateFormatter.wmf_24hshortTimeWithUTCTimeZone(), let date = isoDateFormatter.date(from: timestampString) { if fullyRelative { let relativeTime = (date as NSDate).wmf_fullyLocalizedRelativeDateStringFromLocalDateToNow() return relativeTime } else { let calendar = NSCalendar.current let unitFlags:Set = [.day] let components = calendar.dateComponents(unitFlags, from: date, to: Date()) if let numberOfDays = components.day { switch numberOfDays { case 0: let relativeTime = (date as NSDate).wmf_fullyLocalizedRelativeDateStringFromLocalDateToNow() return relativeTime default: let shortTime = timeDateFormatter.string(from: date) return shortTime } } } } return nil } } // MARK: SectionHeader public extension ArticleAsLivingDocViewModel { class SectionHeader: Hashable { public let title: String public let subtitleTimestampDisplay: String public let timestamp: Date public let dateRange: DateInterval? public var typedEvents: [TypedEvent] private let sectionTimestampIdentifier: String private static let calendar: Calendar = Calendar.current private static let relativeDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.doesRelativeDateFormatting = true formatter.timeStyle = .none formatter.dateStyle = .short return formatter }() private static let dayMonthYearFormatter: DateFormatter = { let formatter = DateFormatter() formatter.setLocalizedDateFormatFromTemplate("MMMM d, yyyy") return formatter }() private static let dayMonthFormatter: DateFormatter = { let formatter = DateFormatter() formatter.setLocalizedDateFormatFromTemplate("MMMM d") return formatter }() public init(timestamp: Date, typedEvents: [TypedEvent], subtitleDateFormatter: DateFormatter, dateRange: DateInterval? = nil) { // If today or yesterday, show friendly localized relative format ("Today", "Yesterday") func relativelyFormat(date: Date) -> String { let nsTimestamp = timestamp as NSDate if SectionHeader.calendar.isDateInToday(date) || SectionHeader.calendar.isDateInYesterday(date) { return SectionHeader.relativeDateFormatter.string(from: date) } else { return nsTimestamp.wmf_fullyLocalizedRelativeDateStringFromLocalDateToNow() } } self.title = relativelyFormat(date: timestamp) if let dateRange = dateRange { var dateRangeStrings: [String] = [] let startDate = dateRange.start let endDate = dateRange.end if SectionHeader.calendar.isDateInToday(endDate) || SectionHeader.calendar.isDateInYesterday(endDate) { let endString = relativelyFormat(date: endDate) let startString = SectionHeader.dayMonthYearFormatter.string(from: startDate) dateRangeStrings.append(contentsOf: [endString, startString]) } else { let endString = SectionHeader.dayMonthFormatter.string(from: endDate) let startString = SectionHeader.dayMonthYearFormatter.string(from: startDate) dateRangeStrings.append(contentsOf: [endString, startString]) } self.subtitleTimestampDisplay = dateRangeStrings.joined(separator: " - ") } else { self.subtitleTimestampDisplay = SectionHeader.dayMonthYearFormatter.string(from: timestamp) } self.dateRange = dateRange self.timestamp = timestamp self.typedEvents = typedEvents self.sectionTimestampIdentifier = subtitleDateFormatter.string(from: timestamp) } public static func == (lhs: ArticleAsLivingDocViewModel.SectionHeader, rhs: ArticleAsLivingDocViewModel.SectionHeader) -> Bool { return lhs.sectionTimestampIdentifier == rhs.sectionTimestampIdentifier } public func hash(into hasher: inout Hasher) { hasher.combine(sectionTimestampIdentifier) hasher.combine(typedEvents) } // MARK: - Helpers public var containsOnlySmallEvents: Bool { typedEvents.count == 1 && typedEvents.allSatisfy { event in event.isSmall } } } } // MARK: Events public extension ArticleAsLivingDocViewModel { enum TypedEvent: Hashable { case small(Event.Small) case large(Event.Large) public static func == (lhs: ArticleAsLivingDocViewModel.TypedEvent, rhs: ArticleAsLivingDocViewModel.TypedEvent) -> Bool { switch lhs { case .large(let leftLargeEvent): switch rhs { case .large(let rightLargeEvent): return leftLargeEvent == rightLargeEvent default: return false } case .small(let leftSmallEvent): switch rhs { case .small(let rightSmallEvent): return leftSmallEvent == rightSmallEvent default: return false } } } public func hash(into hasher: inout Hasher) { switch self { case .small(let smallEvent): smallEvent.smallChanges.forEach { hasher.combine($0.revId) } case .large(let largeEvent): hasher.combine(largeEvent.revId) hasher.combine(largeEvent.wereThanksSent) } } // MARK: - Helpers public var isSmall: Bool { switch self { case .small: return true default: return false } } public var smallChanges: [SignificantEvents.Event.Small] { switch self { case .small(let small): return small.smallChanges default: return [] } } } } public extension ArticleAsLivingDocViewModel { struct Event { public class Small: Equatable { private var lastTraitCollection: UITraitCollection? private var lastTheme: Theme? public lazy var eventDescription = { return String.localizedStringWithFormat( CommonStrings.smallChangeDescription, smallChanges.count) }() public let smallChanges: [SignificantEvents.Event.Small] public var loggingPosition = 0 public init?(typedEvents: [SignificantEvents.TypedEvent]) { var smallChanges: [SignificantEvents.Event.Small] = [] for event in typedEvents { switch event { case .small(let smallChange): smallChanges.append(smallChange) default: return nil } } guard smallChanges.count > 0 else { return nil } self.smallChanges = smallChanges } public init(smallChanges: [SignificantEvents.Event.Small]) { self.smallChanges = smallChanges } public static func == (lhs: ArticleAsLivingDocViewModel.Event.Small, rhs: ArticleAsLivingDocViewModel.Event.Small) -> Bool { return lhs.smallChanges == rhs.smallChanges } // Only used in the html portion of the feature func timestampForDisplay() -> String? { guard let timestampString = smallChanges.first?.timestampString else { return nil } let displayTimestamp = ArticleAsLivingDocViewModel.displayTimestamp(timestampString: timestampString, fullyRelative: true) return displayTimestamp } } public class Large: Equatable { public enum ChangeDetail { case snippet(Snippet) // use for a basic horizontally scrolling snippet cell (will contain talk page topic snippets, added text snippets, article description updated snippets) case reference(Reference) } public struct Snippet { public let description: NSAttributedString } public struct Reference { public let type: String public let description: NSAttributedString public let accessDateYearDisplay: String? init?(type: String, description: NSAttributedString?, accessDateYearDisplay: String?) { guard let description = description else { return nil } self.type = type self.description = description self.accessDateYearDisplay = accessDateYearDisplay } } public enum UserType { case standard case anonymous case bot } public enum ButtonsToDisplay { case thankAndViewChanges(userId: UInt, revisionId: UInt) case viewDiscussion(sectionName: String?) } public let typedEvent: SignificantEvents.TypedEvent private var lastTraitCollection: UITraitCollection? private var lastTheme: Theme? private(set) var eventDescription: NSAttributedString? private(set) var sideScrollingCollectionViewHeight: CGFloat? private(set) var changeDetails: [ChangeDetail]? private(set) var displayTimestamp: String? private(set) var userInfo: NSAttributedString? let userId: UInt public let userType: UserType public let buttonsToDisplay: ButtonsToDisplay public let revId: UInt public let parentId: UInt public var wereThanksSent = false public var loggingPosition = 0 init?(typedEvent: SignificantEvents.TypedEvent) { let userGroups: [String]? switch typedEvent { case .newTalkPageTopic(let newTalkPageTopic): self.userId = newTalkPageTopic.userId userGroups = newTalkPageTopic.userGroups if let talkPageSection = newTalkPageTopic.section { self.buttonsToDisplay = .viewDiscussion(sectionName: Self.sectionTitleWithWikitextAndHtmlStripped(originalTitle: talkPageSection)) } else { self.buttonsToDisplay = .viewDiscussion(sectionName: nil) } case .large(let largeChange): self.userId = largeChange.userId userGroups = largeChange.userGroups self.buttonsToDisplay = .thankAndViewChanges(userId: largeChange.userId, revisionId: largeChange.revId) case .vandalismRevert(let vandalismRevert): self.userId = vandalismRevert.userId userGroups = vandalismRevert.userGroups self.buttonsToDisplay = .thankAndViewChanges(userId: vandalismRevert.userId, revisionId: vandalismRevert.revId) case .small: return nil } if let userGroups = userGroups, userGroups.contains("bot") { userType = .bot } else if self.userId == 0 { userType = .anonymous } else { userType = .standard } self.typedEvent = typedEvent switch typedEvent { case .large(let largeChange): revId = largeChange.revId parentId = largeChange.parentId case .newTalkPageTopic(let newTalkPageTopic): revId = newTalkPageTopic.revId parentId = newTalkPageTopic.parentId case .vandalismRevert(let vandalismRevert): revId = vandalismRevert.revId parentId = vandalismRevert.parentId default: assertionFailure("Shouldn't happen") revId = 0 parentId = 0 } } public static func == (lhs: ArticleAsLivingDocViewModel.Event.Large, rhs: ArticleAsLivingDocViewModel.Event.Large) -> Bool { return lhs.revId == rhs.revId && lhs.wereThanksSent == rhs.wereThanksSent } } } } // MARK: Large Event Type Helper methods public extension ArticleAsLivingDocViewModel.Event.Large { func articleInsertHtmlSnippet(isFirst: Bool = false, isLast: Bool = false, indexPath: IndexPath) -> String? { guard let timestampForDisplay = self.fullyRelativeTimestampForDisplay(), let userInfo = userInfoHtmlSnippet() else { return nil } let liElementIdName = isFirst ? "significant-changes-first-list" : isLast ? "significant-changes-last-list" : "significant-changes-list" let lastUserInfoIdAdditions = isLast ? " id='significant-changes-userInfo-last'" : "" return "
  • \(timestampForDisplay)

    \(eventDescription ?? NSAttributedString(string: ""))

    \(userInfo)

  • " } private var htmlSignificantEventsLinkEndingTag: String { return "" } // if trait collection or theme is different from the last time attributed strings were generated, // reset to nil to trigger generation again the next time it's requested func resetAttributedStringsIfNeededWithTraitCollection(_ traitCollection: UITraitCollection, theme: Theme) { if let lastTraitCollection = lastTraitCollection, let lastTheme = lastTheme, lastTraitCollection != traitCollection || lastTheme != theme { eventDescription = nil sideScrollingCollectionViewHeight = nil changeDetails = nil userInfo = nil Self.heightForThreeLineSnippet = nil Self.heightForReferenceTitle = nil } lastTraitCollection = traitCollection lastTheme = theme } struct IndividualDescription { let priority: Int // used for sorting let text: String } private func individualDescriptionsForTypedChanges(_ typedChanges: [SignificantEvents.TypedChange]) -> [IndividualDescription] { var descriptions: [IndividualDescription] = [] var numReferences = 0 for typedChange in typedChanges { switch typedChange { case .addedText(let addedText): let countNumber = NSNumber(value: addedText.characterCount) let characterCount = "\(NumberFormatter.localizedThousandsStringFromNumber(countNumber).localizedLowercase) " + String.localizedStringWithFormat(CommonStrings.charactersTextDescription, addedText.characterCount) let description = String.localizedStringWithFormat(CommonStrings.addedTextDescription, characterCount) descriptions.append(IndividualDescription(priority: 1, text: description)) case .deletedText(let deletedText): let countNumber = NSNumber(value: deletedText.characterCount) let characterCount = "\(NumberFormatter.localizedThousandsStringFromNumber(countNumber).localizedLowercase) " + String.localizedStringWithFormat(CommonStrings.charactersTextDescription, deletedText.characterCount) let description = String.localizedStringWithFormat(CommonStrings.deletedTextDescription, characterCount) descriptions.append(IndividualDescription(priority: 2, text: description)) case .newTemplate(let newTemplate): var numArticleDescriptions = 0 for template in newTemplate.typedTemplates { switch template { case .articleDescription: numArticleDescriptions += 1 case .bookCitation, .journalCitation, .newsCitation, .websiteCitation: numReferences += 1 } } if numArticleDescriptions > 0 { let description = CommonStrings.articleDescriptionUpdatedDescription descriptions.append(IndividualDescription(priority: 3, text: description)) } } } if descriptions.count == 0 { switch numReferences { case 0: break case 1: let description = CommonStrings.singleReferenceAddedDescription descriptions.append(IndividualDescription(priority: 0, text: description)) default: let description = CommonStrings.multipleReferencesAddedDescription descriptions.append(IndividualDescription(priority: 0, text: description)) } } else { if numReferences > 0 { let description = String.localizedStringWithFormat(CommonStrings.numericalMultipleReferencesAddedDescription, numReferences) descriptions.append(IndividualDescription(priority: 0, text: description)) } } return descriptions } private func sectionsSet() -> Set { let set: Set switch typedEvent { case .newTalkPageTopic: set = Set() case .vandalismRevert(let vandalismRevert): set = Set(vandalismRevert.sections) case .large(let largeChange): var sections: [String] = [] for typedChange in largeChange.typedChanges { switch typedChange { case .addedText(let addedTextChange): sections.append(contentsOf: addedTextChange.sections) case .deletedText(let deletedTextChange): sections.append(contentsOf: deletedTextChange.sections) case .newTemplate(let newTemplate): sections.append(contentsOf: newTemplate.sections) } } set = Set(sections) case .small: assertionFailure("This shouldn't happen") set = Set() } // strip == signs from all section titles let finalSet = set.map { Self.sectionTitleWithWikitextAndHtmlStripped(originalTitle: $0) } return Set(finalSet) } // remove one or more equal signs and zero or more spaces on either side of the title text // also removing html for display and potential javascript injection issues - https://phabricator.wikimedia.org/T268201 private static func sectionTitleWithWikitextAndHtmlStripped(originalTitle: String) -> String { var loopTitle = originalTitle.removingHTML let regex = "^=+\\s*|\\s*=+$" var maybeMatch = loopTitle.range(of: regex, options: .regularExpression) while let match = maybeMatch { loopTitle.removeSubrange(match) maybeMatch = loopTitle.range(of: regex, options: .regularExpression) } return loopTitle } private func localizedStringFromSections(sections: [String]) -> String? { var localizedString: String switch sections.count { case 0: return nil case 1: let firstSection = sections[0] localizedString = String.localizedStringWithFormat(CommonStrings.oneSectionDescription, firstSection) case 2: let firstSection = sections[0] let secondSection = sections[1] localizedString = String.localizedStringWithFormat(CommonStrings.twoSectionsDescription, firstSection, secondSection) default: localizedString = String.localizedStringWithFormat(CommonStrings.manySectionsDescription, sections.count) } return " " + localizedString } private func localizedSectionHtmlSnippet(sectionsSet: Set) -> String? { let sections = Array(sectionsSet) guard let localizedString = localizedStringFromSections(sections: sections) else { return nil } let mutableLocalizedString = NSMutableString(string: localizedString) var ranges: [NSRange] = [] for section in sections { let rangeOfSection = (localizedString as NSString).range(of: section) let rangeValid = rangeOfSection.location != NSNotFound && rangeOfSection.location + rangeOfSection.length <= localizedString.count if rangeValid { ranges.append(rangeOfSection) } } var offset = 0 for range in ranges { let italicStart = "" let italicEnd = "" mutableLocalizedString.insert(italicStart, at: range.location + offset) offset += italicStart.count mutableLocalizedString.insert(italicEnd, at: range.location + range.length + offset) offset += italicEnd.count } if let returnString = mutableLocalizedString.copy() as? NSString { return returnString as String } else { assertionFailure("This shouldn't happen") return nil } } private func localizedSectionAttributedString(sectionsSet: Set, traitCollection: UITraitCollection, theme: Theme) -> NSAttributedString? { let sections = Array(sectionsSet) guard let localizedString = localizedStringFromSections(sections: sections) else { return nil } let font = UIFont.wmf_font(.body, compatibleWithTraitCollection: traitCollection) let italicFont = UIFont.wmf_font(.italicBody, compatibleWithTraitCollection: traitCollection) let attributes = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: theme.colors.primaryText] var ranges: [NSRange] = [] for section in sections { let rangeOfSection = (localizedString as NSString).range(of: section) let rangeValid = rangeOfSection.location != NSNotFound && rangeOfSection.location + rangeOfSection.length <= localizedString.count if rangeValid { ranges.append(rangeOfSection) } } let mutableAttributedString = NSMutableAttributedString(string: localizedString, attributes: attributes) for range in ranges { mutableAttributedString.addAttribute(NSAttributedString.Key.font, value: italicFont, range: range) } guard let attributedString = mutableAttributedString.copy() as? NSAttributedString else { assertionFailure("This shouldn't happen") return NSAttributedString(string: localizedString, attributes: attributes) } return attributedString } static let sideScrollingCellPadding = UIEdgeInsets(top: 17, left: 15, bottom: 17, right: 15) static let sideScrollingCellWidth: CGFloat = 250 static var availableSideScrollingCellWidth: CGFloat = { return sideScrollingCellWidth - sideScrollingCellPadding.left - sideScrollingCellPadding.right }() private static let changeDetailDescriptionTextStyle = DynamicTextStyle.subheadline private static let changeDetailDescriptionTextStyleItalic = DynamicTextStyle.italicSubheadline private static let changeDetailDescriptionFontWeight = UIFont.Weight.regular static let changeDetailReferenceTitleStyle = DynamicTextStyle.semiboldSubheadline static let changeDetailReferenceTitleDescriptionSpacing: CGFloat = 13 static let additionalPointsForShadow: CGFloat = 16 @discardableResult func calculateSideScrollingCollectionViewHeightForTraitCollection(_ traitCollection: UITraitCollection, theme: Theme) -> CGFloat { if let sideScrollingCollectionViewHeight = sideScrollingCollectionViewHeight { return sideScrollingCollectionViewHeight } let changeDetails = changeDetailsForTraitCollection(traitCollection, theme: theme) let tallestSnippetContentHeight: CGFloat = calculateTallestSnippetContentHeightInChangeDetails(changeDetails: changeDetails) let tallestReferenceChangeDetailHeight: CGFloat = calculateTallestReferenceContentHeightInChangeDetails(changeDetails: changeDetails, traitCollection: traitCollection, theme: theme) let maxContentHeight = maxContentHeightFromTallestSnippetContentHeight(tallestSnippetContentHeight: tallestSnippetContentHeight, tallestReferenceContentHeight: tallestReferenceChangeDetailHeight, traitCollection: traitCollection, theme: theme) let finalHeight = maxContentHeight == 0 ? 0 : maxContentHeight + Self.sideScrollingCellPadding.top + Self.sideScrollingCellPadding.bottom + Self.additionalPointsForShadow self.sideScrollingCollectionViewHeight = finalHeight return finalHeight } func changeDetailsForTraitCollection(_ traitCollection: UITraitCollection, theme: Theme) -> [ChangeDetail] { if let changeDetails = changeDetails { return changeDetails } var changeDetails: [ChangeDetail] = [] switch typedEvent { case .newTalkPageTopic(let newTalkPageTopic): let attributedString = newTalkPageTopic.snippet.byAttributingHTML(with: Self.changeDetailDescriptionTextStyle, boldWeight: Self.changeDetailDescriptionFontWeight, matching: traitCollection, color: theme.colors.primaryText, linkColor: theme.colors.link, handlingLists: true, handlingSuperSubscripts: true) let changeDetail = ChangeDetail.snippet(Snippet(description: attributedString)) changeDetails.append(changeDetail) case .large(let largeChange): for typedChange in largeChange.typedChanges { switch typedChange { case .addedText(let addedText): // TODO: Add highlighting here. For snippetType 1, add a highlighting attribute across the whole string. Otherwise, seek out highlight-add span ranges and add those attributes guard let snippet = addedText.snippet else { continue } let attributedString = snippet.byAttributingHTML(with: Self.changeDetailDescriptionTextStyle, boldWeight: Self.changeDetailDescriptionFontWeight, matching: traitCollection, color: theme.colors.primaryText, handlingLinks: true, linkColor: theme.colors.link, handlingLists: true, handlingSuperSubscripts: true) let changeDetail = ChangeDetail.snippet(Snippet(description: attributedString)) changeDetails.append(changeDetail) case .deletedText: continue case .newTemplate(let newTemplate): for template in newTemplate.typedTemplates { switch template { case .articleDescription(let articleDescription): let font = UIFont.wmf_font(Self.changeDetailDescriptionTextStyle, compatibleWithTraitCollection: traitCollection) let attributes = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: theme.colors.primaryText] let attributedString = NSAttributedString(string: articleDescription.text, attributes: attributes) let changeDetail = ChangeDetail.snippet(Snippet(description: attributedString)) changeDetails.append(changeDetail) // tonitodo: these code blocks are all very similar. make a generic method instead? case .bookCitation(let bookCitation): let typeText = referenceTypeForTemplate(template, traitCollection: traitCollection, theme: theme) let accessYear = accessDateYearForTemplate(template, traitCollection: traitCollection, theme: theme) let bookCitationDescription = descriptionForBookCitation(bookCitation, traitCollection: traitCollection, theme: theme) if let reference = Reference(type: typeText, description: bookCitationDescription, accessDateYearDisplay: accessYear) { let changeDetail = ChangeDetail.reference(reference) changeDetails.append(changeDetail) } case .journalCitation(let journalCitation): let typeText = referenceTypeForTemplate(template, traitCollection: traitCollection, theme: theme) let accessYear = accessDateYearForTemplate(template, traitCollection: traitCollection, theme: theme) let citationDescription = descriptionForJournalCitation(journalCitation, traitCollection: traitCollection, theme: theme) if let reference = Reference(type: typeText, description: citationDescription, accessDateYearDisplay: accessYear) { let changeDetail = ChangeDetail.reference(reference) changeDetails.append(changeDetail) } case .newsCitation(let newsCitation): let typeText = referenceTypeForTemplate(template, traitCollection: traitCollection, theme: theme) let accessYear = accessDateYearForTemplate(template, traitCollection: traitCollection, theme: theme) let citationDescription = descriptionForNewsCitation(newsCitation, traitCollection: traitCollection, theme: theme) if let reference = Reference(type: typeText, description: citationDescription, accessDateYearDisplay: accessYear) { let changeDetail = ChangeDetail.reference(reference) changeDetails.append(changeDetail) } case .websiteCitation(let websiteCitation): let typeText = referenceTypeForTemplate(template, traitCollection: traitCollection, theme: theme) let accessYear = accessDateYearForTemplate(template, traitCollection: traitCollection, theme: theme) let citationDescription = descriptionForWebsiteCitation(websiteCitation, traitCollection: traitCollection, theme: theme) if let reference = Reference(type: typeText, description: citationDescription, accessDateYearDisplay: accessYear) { let changeDetail = ChangeDetail.reference(reference) changeDetails.append(changeDetail) } } } } } case .vandalismRevert: return [] case .small: assertionFailure("This should not happen") return [] } self.changeDetails = changeDetails return changeDetails } // Note: heightForThreeLineSnippet and heightForReferenceTitle methods are placeholder calculations when determining a side scrolling cell's content height. // When there are no reference cells, we are capping off article content snippet cells at 3 lines. If there are reference cells, snippet cells are allowed to show lines to the full height of the tallest reference cell. // Reference cells titles are only ever 1 line, so we are using placeholder text to calculate that rather than going up against actual view model title values, since the height will be the same regardless of the size of the title value // heightForThreeLine and heightForReferenceTitle only ever needs to be calculated once per traitCollection's preferredContentSize, so we are optimizing in the similar way that ArticleAsLivingDocViewModel's various NSAttributedStrings are optimized, i.e. calculate once, then reset when the traitCollection changes via resetAttributedStringsIfNeededWithTraitCollection. private static var heightForThreeLineSnippet: CGFloat? private static func heightForThreeLineSnippetForTraitCollection(_ traitCollection: UITraitCollection, theme: Theme) -> CGFloat { if let heightForThreeLineSnippet = heightForThreeLineSnippet { return heightForThreeLineSnippet } let snippetFont = UIFont.wmf_font(Self.changeDetailDescriptionTextStyle, compatibleWithTraitCollection: traitCollection) let snippetAttributes = [NSAttributedString.Key.font: snippetFont] let threeLineSnippetText = """ 1 2 3 """ let threeLineSnippetAttString = NSAttributedString(string: threeLineSnippetText, attributes: snippetAttributes) let finalHeight = ceil(threeLineSnippetAttString.boundingRect(with: CGSize(width: Self.availableSideScrollingCellWidth, height: CGFloat.infinity), options: [.usesLineFragmentOrigin], context: nil).height) heightForThreeLineSnippet = finalHeight return finalHeight } private static var heightForReferenceTitle: CGFloat? private static func heightForReferenceTitleForTraitCollection(_ traitCollection: UITraitCollection, theme: Theme) -> CGFloat { if let heightForReferenceTitle = heightForReferenceTitle { return heightForReferenceTitle } let referenceTitleFont = UIFont.wmf_font(Self.changeDetailReferenceTitleStyle, compatibleWithTraitCollection: traitCollection) let referenceTitleAttributes = [NSAttributedString.Key.font: referenceTitleFont] let oneLineTitleText = "1" let oneLineTitleAttString = NSAttributedString(string: oneLineTitleText, attributes: referenceTitleAttributes) let finalHeight = ceil(oneLineTitleAttString.boundingRect(with: CGSize(width: Self.availableSideScrollingCellWidth, height: CGFloat.infinity), options: [.usesLineFragmentOrigin], context: nil).height) heightForReferenceTitle = finalHeight return finalHeight } private func calculateTallestSnippetContentHeightInChangeDetails(changeDetails: [ChangeDetail]) -> CGFloat { var tallestSnippetChangeDetailHeight: CGFloat = 0 changeDetails.forEach { (changeDetail) in switch changeDetail { case .snippet(let snippet): let snippetHeight = ceil(snippet.description.boundingRect(with: CGSize(width: Self.availableSideScrollingCellWidth, height: CGFloat.infinity), options: [.usesLineFragmentOrigin], context: nil).height) if tallestSnippetChangeDetailHeight < snippetHeight { tallestSnippetChangeDetailHeight = snippetHeight } case .reference: break } } return tallestSnippetChangeDetailHeight } private func calculateTallestReferenceContentHeightInChangeDetails(changeDetails: [ChangeDetail], traitCollection: UITraitCollection, theme: Theme) -> CGFloat { var tallestReferenceChangeDetailHeight: CGFloat = 0 changeDetails.forEach { (changeDetail) in switch changeDetail { case .snippet: break case .reference(let reference): let titleHeight = Self.heightForReferenceTitleForTraitCollection(traitCollection, theme: theme) let descriptionHeight = ceil(reference.description.boundingRect(with: CGSize(width: Self.availableSideScrollingCellWidth, height: CGFloat.infinity), options: [.usesLineFragmentOrigin], context: nil).height) let totalHeight = titleHeight + Self.changeDetailReferenceTitleDescriptionSpacing + descriptionHeight if tallestReferenceChangeDetailHeight < totalHeight { tallestReferenceChangeDetailHeight = totalHeight } } } return tallestReferenceChangeDetailHeight } private func maxContentHeightFromTallestSnippetContentHeight(tallestSnippetContentHeight: CGFloat, tallestReferenceContentHeight: CGFloat, traitCollection: UITraitCollection, theme: Theme) -> CGFloat { guard tallestSnippetContentHeight > 0 else { return tallestReferenceContentHeight } let threeLineSnippetHeight = Self.heightForThreeLineSnippetForTraitCollection(traitCollection, theme: theme) if tallestReferenceContentHeight == 0 { return min(tallestSnippetContentHeight, threeLineSnippetHeight) } else { let finalSnippetHeight = min(tallestSnippetContentHeight, threeLineSnippetHeight) return max(tallestReferenceContentHeight, finalSnippetHeight) } } private func referenceTypeForTemplate(_ template: SignificantEvents.Template, traitCollection: UITraitCollection, theme: Theme) -> String { var typeString: String switch template { case .articleDescription: assertionFailure("This should not happen") return "" case .bookCitation: typeString = CommonStrings.newBookReferenceTitle case .journalCitation: typeString = CommonStrings.newJournalReferenceTitle case .newsCitation: typeString = CommonStrings.newNewsReferenceTitle case .websiteCitation: typeString = CommonStrings.newWebsiteReferenceTitle } return typeString } private func accessDateYearForTemplate(_ template: SignificantEvents.Template, traitCollection: UITraitCollection, theme: Theme) -> String? { let accessDateString: String? switch template { case .newsCitation(let newsCitation): accessDateString = newsCitation.accessDateString case .websiteCitation(let websiteCitation): accessDateString = websiteCitation.accessDateString default: return nil } if let accessDateString = accessDateString { let dateFormatter = DateFormatter.wmf_monthNameDayOfMonthNumberYear() if let date = dateFormatter?.date(from: accessDateString) { let yearDateFormatter = DateFormatter.wmf_year() let year = yearDateFormatter?.string(from: date) return year } } return nil } private func descriptionForJournalCitation(_ journalCitation: SignificantEvents.Citation.Journal, traitCollection: UITraitCollection, theme: Theme) -> NSAttributedString? { let font = UIFont.wmf_font(Self.changeDetailDescriptionTextStyle, compatibleWithTraitCollection: traitCollection) let boldFont = UIFont.wmf_font(Self.changeDetailDescriptionTextStyleItalic, compatibleWithTraitCollection: traitCollection) let attributes = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: theme.colors.primaryText] let boldAttributes = [NSAttributedString.Key.font: boldFont, NSAttributedString.Key.foregroundColor: theme.colors.primaryText] let titleString = "\"\(journalCitation.title)\" " let mutableAttributedString = mutableString(from: titleString, linkedTo: journalCitation.urlString, with: attributes, linkColor: theme.colors.link) let titleAttributedString = mutableAttributedString.copy() as? NSAttributedString var descriptionStart = "" if let firstName = journalCitation.firstName { if let lastName = journalCitation.lastName { descriptionStart += "\(lastName), \(firstName)" } } else { if let lastName = journalCitation.lastName { descriptionStart += "\(lastName)" } } if let sourceDate = journalCitation.sourceDateString { descriptionStart += " (\(sourceDate)). " } else { descriptionStart += ". " } let descriptionStartAttributedString = NSAttributedString(string: descriptionStart, attributes: attributes) var volumeString = "" if let volumeNumber = journalCitation.volumeNumber { volumeString = String.localizedStringWithFormat(CommonStrings.newJournalReferenceVolume, volumeNumber) } let volumeAttributedString = NSAttributedString(string: "\(volumeString) ", attributes: boldAttributes) var descriptionEnd = "" if let database = journalCitation.database { let viaDatabaseString = String.localizedStringWithFormat(CommonStrings.newJournalReferenceDatabase, database) if let pages = journalCitation.pages { descriptionEnd += "\(pages) - \(viaDatabaseString)." } else { descriptionEnd += "\(viaDatabaseString)." } } else { if let pages = journalCitation.pages { descriptionEnd += "\(pages)." } } let descriptionEndAttributedString = NSAttributedString(string: descriptionEnd, attributes: attributes) let finalMutableAttributedString = NSMutableAttributedString(string: "") finalMutableAttributedString.append(descriptionStartAttributedString) if let titleAttributedString = titleAttributedString { finalMutableAttributedString.append(titleAttributedString) } finalMutableAttributedString.append(volumeAttributedString) finalMutableAttributedString.append(descriptionEndAttributedString) return finalMutableAttributedString.copy() as? NSAttributedString } private func descriptionForWebsiteCitation(_ websiteCitation: SignificantEvents.Citation.Website, traitCollection: UITraitCollection, theme: Theme) -> NSAttributedString? { let font = UIFont.wmf_font(Self.changeDetailDescriptionTextStyle, compatibleWithTraitCollection: traitCollection) let italicFont = UIFont.wmf_font(Self.changeDetailDescriptionTextStyleItalic, compatibleWithTraitCollection: traitCollection) let attributes = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: theme.colors.primaryText] let italicAttributes = [NSAttributedString.Key.font: italicFont, NSAttributedString.Key.foregroundColor: theme.colors.primaryText] let titleString = "\"\(websiteCitation.title)\" " let mutableAttributedString = mutableString(from: titleString, linkedTo: websiteCitation.urlString, with: attributes, linkColor: theme.colors.link) let titleAttributedString = mutableAttributedString.copy() as? NSAttributedString var publisherText = "" if let publisher = websiteCitation.publisher { publisherText = "\(publisher). " } let publisherAttributedString = NSAttributedString(string: publisherText, attributes: italicAttributes) var accessDateString = "" if let accessDate = websiteCitation.accessDateString { accessDateString = "\(accessDate). " } let accessDateAttributedString = NSAttributedString(string: accessDateString, attributes: attributes) let finalMutableAttributedString = NSMutableAttributedString(string: "") if let titleAttributedString = titleAttributedString { finalMutableAttributedString.append(titleAttributedString) } finalMutableAttributedString.append(publisherAttributedString) finalMutableAttributedString.append(accessDateAttributedString) if let archiveDateString = websiteCitation.archiveDateString, let archiveUrlString = websiteCitation.archiveDotOrgUrlString, URL(string: archiveUrlString) != nil { let archiveLinkText = CommonStrings.newWebsiteReferenceArchiveUrlText let archiveLinkMutableAttributedString = mutableString(from: archiveLinkText, linkedTo: archiveUrlString, with: attributes, linkColor: theme.colors.link) if let archiveLinkAttributedString = archiveLinkMutableAttributedString.copy() as? NSAttributedString { let lastText = String.localizedStringWithFormat(CommonStrings.newWebsiteReferenceArchiveDateText, archiveDateString) let lastAttributedString = NSAttributedString(string: " \(lastText)", attributes: attributes) finalMutableAttributedString.append(archiveLinkAttributedString) finalMutableAttributedString.append(lastAttributedString) } } return finalMutableAttributedString.copy() as? NSAttributedString } private func descriptionForNewsCitation(_ newsCitation: SignificantEvents.Citation.News, traitCollection: UITraitCollection, theme: Theme) -> NSAttributedString? { let font = UIFont.wmf_font(Self.changeDetailDescriptionTextStyle, compatibleWithTraitCollection: traitCollection) let italicFont = UIFont.wmf_font(Self.changeDetailDescriptionTextStyleItalic, compatibleWithTraitCollection: traitCollection) let attributes = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: theme.colors.primaryText] let italicAttributes = [NSAttributedString.Key.font: italicFont, NSAttributedString.Key.foregroundColor: theme.colors.primaryText] let titleString = "\"\(newsCitation.title)\" " let mutableAttributedString = mutableString(from: titleString, linkedTo: newsCitation.urlString, with: attributes, linkColor: theme.colors.link) let titleAttributedString = mutableAttributedString.copy() as? NSAttributedString var descriptionStart = "" if let firstName = newsCitation.firstName { if let lastName = newsCitation.lastName { descriptionStart += "\(lastName), \(firstName) " } } else { if let lastName = newsCitation.lastName { descriptionStart += "\(lastName) " } } if let sourceDate = newsCitation.sourceDateString { descriptionStart += "(\(sourceDate)). " } else { descriptionStart += ". " } let descriptionStartAttributedString = NSAttributedString(string: descriptionStart, attributes: attributes) var publicationText = "" if let publication = newsCitation.publication { publicationText = "\(publication). " } let publicationAttributedString = NSAttributedString(string: publicationText, attributes: italicAttributes) var retrievedString = "" if let accessDate = newsCitation.accessDateString { retrievedString = String.localizedStringWithFormat(CommonStrings.newNewsReferenceRetrievedDate, accessDate) } let retrievedDateAttributedString = NSAttributedString(string: "\(retrievedString) ", attributes: attributes) let finalMutableAttributedString = NSMutableAttributedString(string: "") finalMutableAttributedString.append(descriptionStartAttributedString) if let titleAttributedString = titleAttributedString { finalMutableAttributedString.append(titleAttributedString) } finalMutableAttributedString.append(publicationAttributedString) finalMutableAttributedString.append(retrievedDateAttributedString) return finalMutableAttributedString.copy() as? NSAttributedString } private func descriptionForBookCitation(_ bookCitation: SignificantEvents.Citation.Book, traitCollection: UITraitCollection, theme: Theme) -> NSAttributedString? { let font = UIFont.wmf_font(Self.changeDetailDescriptionTextStyle, compatibleWithTraitCollection: traitCollection) let italicFont = UIFont.wmf_font(Self.changeDetailDescriptionTextStyleItalic, compatibleWithTraitCollection: traitCollection) let attributes = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: theme.colors.primaryText] let italicAttributes = [NSAttributedString.Key.font: italicFont, NSAttributedString.Key.foregroundColor: theme.colors.primaryText] let titleAttributedString = NSAttributedString(string: "\(bookCitation.title) ", attributes: italicAttributes) var descriptionStart = "" if let firstName = bookCitation.firstName { if let lastName = bookCitation.lastName { descriptionStart += "\(lastName), \(firstName)" } } else { if let lastName = bookCitation.lastName { descriptionStart += "\(lastName)" } } if let yearOfPub = bookCitation.yearPublished { descriptionStart += " (\(yearOfPub)). " } else { descriptionStart += ". " } let descriptionStartAttributedString = NSAttributedString(string: descriptionStart, attributes: attributes) var descriptionMiddle = "" if let locationPublished = bookCitation.locationPublished { if let publisher = bookCitation.publisher { descriptionMiddle += "\(locationPublished): \(publisher). " } else { descriptionMiddle += "\(locationPublished). " } } else { if let publisher = bookCitation.publisher { descriptionMiddle += "\(publisher). " } } if let pagesCited = bookCitation.pagesCited { descriptionMiddle += "pp. \(pagesCited) " } let descriptionMiddleAttributedString = NSAttributedString(string: descriptionMiddle, attributes: attributes) var isbnAttributedString: NSAttributedString? if let isbn = bookCitation.isbn { let isbnPrefix = "ISBN: " let mutableAttributedString = NSMutableAttributedString(string: "\(isbnPrefix + isbn)", attributes: attributes) let isbnTitle = "Special:BookSources" let isbnURL = Configuration.current.articleURLForHost(Configuration.Domain.englishWikipedia, languageVariantCode: nil, appending: [isbnTitle, isbn]) let range = NSRange(location: 0, length: isbnPrefix.count + isbn.count) if let isbnURL = isbnURL { mutableAttributedString.addAttributes([NSAttributedString.Key.link : isbnURL, NSAttributedString.Key.foregroundColor: theme.colors.link], range: range) } else { mutableAttributedString.addAttributes(attributes, range: range) } isbnAttributedString = mutableAttributedString.copy() as? NSAttributedString } let finalMutableAttributedString = NSMutableAttributedString(string: "") finalMutableAttributedString.append(descriptionStartAttributedString) finalMutableAttributedString.append(titleAttributedString) finalMutableAttributedString.append(descriptionMiddleAttributedString) if let isbnAttributedString = isbnAttributedString { finalMutableAttributedString.append(isbnAttributedString) } return finalMutableAttributedString.copy() as? NSAttributedString } private func mutableString(from text: String, linkedTo urlString: String?, with textAttributes: [NSAttributedString.Key:Any], linkColor: UIColor) -> NSMutableAttributedString { let mutableAttributedString: NSMutableAttributedString if let urlString = urlString, let url = URL(string: urlString), let externalLinkIcon = UIImage(named: "mini-external") { mutableAttributedString = NSMutableAttributedString(string: text.trimmingCharacters(in: .whitespaces), attributes: textAttributes) mutableAttributedString.append(NSAttributedString(string: " ")) let externalLinkString = NSAttributedString(attachment: NSTextAttachment(image: externalLinkIcon)) mutableAttributedString.append(externalLinkString) mutableAttributedString.append(NSAttributedString(string: " ")) let range = NSRange(location: 0, length: mutableAttributedString.length) mutableAttributedString.addAttributes([NSAttributedString.Key.link : url, NSAttributedString.Key.foregroundColor: linkColor], range: range) } else { mutableAttributedString = NSMutableAttributedString(string: text, attributes: textAttributes) let range = NSRange(location: 0, length: text.count) mutableAttributedString.addAttributes(textAttributes, range: range) } return mutableAttributedString } private func getTimestampString() -> String? { switch typedEvent { case .newTalkPageTopic(let newTalkPageTopic): return newTalkPageTopic.timestampString case .large(let largeChange): return largeChange.timestampString case .vandalismRevert(let vandalismRevert): return vandalismRevert.timestampString case .small: return nil } } // Only used in the html portion of the feature func fullyRelativeTimestampForDisplay() -> String? { guard let timestampString = getTimestampString() else { return nil } return ArticleAsLivingDocViewModel.displayTimestamp(timestampString: timestampString, fullyRelative: true) } func timestampForDisplay() -> String? { if let displayTimestamp = displayTimestamp { return displayTimestamp } else if let timestampString = getTimestampString() { self.displayTimestamp = ArticleAsLivingDocViewModel.eventDisplayTimestamp(timestampString: timestampString) } return displayTimestamp } private func userNameAndEditCount() -> (userName: String, editCount: UInt?)? { let userName: String let editCount: UInt? switch typedEvent { case .newTalkPageTopic(let newTalkPageTopic): userName = newTalkPageTopic.user editCount = newTalkPageTopic.userEditCount case .large(let largeChange): userName = largeChange.user editCount = largeChange.userEditCount case .vandalismRevert(let vandalismRevert): userName = vandalismRevert.user editCount = vandalismRevert.userEditCount case .small: return nil } return (userName: userName, editCount: editCount) } static var botIconName: String { return "article-as-living-doc-svg-bot" } static var anonymousIconName: String = "article-as-living-doc-svg-anon" private func userInfoHtmlSnippet() -> String? { guard let userNameAndEditCount = self.userNameAndEditCount() else { assertionFailure("Shouldn't reach this point") return nil } let userName = userNameAndEditCount.userName let editCount = userNameAndEditCount.editCount if let editCount = editCount, userType != .anonymous { let formattedEditCount = NumberFormatter.localizedThousandsStringFromNumber(NSNumber(value: editCount)).localizedLowercase let userInfo = String.localizedStringWithFormat(CommonStrings.revisionUserInfo, userName, formattedEditCount) let rangeOfUserName = (userInfo as NSString).range(of: userName) let rangeValid = rangeOfUserName.location != NSNotFound && rangeOfUserName.location + rangeOfUserName.length <= userInfo.count let userNameHrefString = "#significant-events-username-\(userName)" if rangeValid { let mutableUserInfo = NSMutableString(string: userInfo) let linkStartInsert: String if userType == .bot { linkStartInsert = "" } else { linkStartInsert = "" } let linkEndInsert = "" mutableUserInfo.insert(linkStartInsert, at: rangeOfUserName.location) mutableUserInfo.insert(linkEndInsert, at: rangeOfUserName.location + rangeOfUserName.length + linkStartInsert.count) if let userInfoResult = mutableUserInfo.copy() as? NSString { return (userInfoResult as String) } else { assertionFailure("This shouldn't happen") return nil } } } else { return "\(CommonStrings.revisionUserInfoAnonymous)" } return nil } func userInfoForTraitCollection(_ traitCollection: UITraitCollection, theme: Theme) -> NSAttributedString? { if let userInfo = userInfo { return userInfo } guard let userNameAndEditCount = self.userNameAndEditCount() else { assertionFailure("Shouldn't reach this point") return nil } let userName = userNameAndEditCount.userName let maybeEditCount = userNameAndEditCount.editCount guard let editCount = maybeEditCount, userType != .anonymous else { let anonymousUserInfo = CommonStrings.revisionUserInfoAnonymous let font = UIFont.wmf_font(.subheadline, compatibleWithTraitCollection: traitCollection) let attributes = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: theme.colors.secondaryText] let mutableAttributedString = NSMutableAttributedString(string: anonymousUserInfo, attributes: attributes) addIcon(to: mutableAttributedString, at: 0, for: userType) // Need this next line to appropriately color the icon mutableAttributedString.addAttributes(attributes, range: NSRange(location: 0, length: 2)) guard let attributedString = mutableAttributedString.copy() as? NSAttributedString else { return nil } self.userInfo = attributedString return attributedString } let formattedEditCount = NumberFormatter.localizedThousandsStringFromNumber(NSNumber(value: editCount)).localizedLowercase let userInfo = String.localizedStringWithFormat( CommonStrings.revisionUserInfo, userName, formattedEditCount) let font = UIFont.wmf_font(.subheadline, compatibleWithTraitCollection: traitCollection) let attributes = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: theme.colors.secondaryText] let rangeOfUserName = (userInfo as NSString).range(of: userName) let rangeValid = rangeOfUserName.location != NSNotFound && rangeOfUserName.location + rangeOfUserName.length <= userInfo.count guard let title = "User:\(userName)".percentEncodedPageTitleForPathComponents, let userNameURL = Configuration.current.articleURLForHost(Configuration.Domain.englishWikipedia, languageVariantCode: nil, appending: [title]), rangeValid else { let attributedString = NSAttributedString(string: userInfo, attributes: attributes) self.userInfo = attributedString return attributedString } let mutableAttributedString = NSMutableAttributedString(string: userInfo, attributes: attributes) mutableAttributedString.addAttribute(NSAttributedString.Key.link, value: userNameURL as NSURL, range: rangeOfUserName) mutableAttributedString.addAttribute(NSAttributedString.Key.foregroundColor, value: theme.colors.link, range: rangeOfUserName) addIcon(to: mutableAttributedString, at: rangeOfUserName.location, for: userType) guard let attributedString = mutableAttributedString.copy() as? NSAttributedString else { return nil } self.userInfo = attributedString return attributedString } func addIcon(to mutableAttributedString: NSMutableAttributedString, at location: Int, for userType: UserType) { if userType == .bot || userType == .anonymous { let imageAttachment = NSTextAttachment() imageAttachment.image = UIImage(named: (userType == .bot ? Self.botIconName : Self.anonymousIconName)) let imageString = NSAttributedString(attachment: imageAttachment) mutableAttributedString.insert(imageString, at: location) mutableAttributedString.insert(NSAttributedString(string: " "), at: location + imageString.length) } } }