1615 lines
77 KiB
Swift
1615 lines
77 KiB
Swift
|
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<Calendar.Component> = [.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<Int>] = []
|
||
|
|
||
|
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<Calendar.Component> = [.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 "<li id='\(liElementIdName)'><p class='significant-changes-timestamp'>\(timestampForDisplay)</p><p class='significant-changes-description'>\(eventDescription ?? NSAttributedString(string: ""))</p><p class='significant-changes-userInfo'\(lastUserInfoIdAdditions)>\(userInfo)</p></li>"
|
||
|
}
|
||
|
|
||
|
private var htmlSignificantEventsLinkEndingTag: String {
|
||
|
return "</a>"
|
||
|
}
|
||
|
|
||
|
// 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<String> {
|
||
|
let set: Set<String>
|
||
|
switch typedEvent {
|
||
|
case .newTalkPageTopic:
|
||
|
set = Set<String>()
|
||
|
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<String>()
|
||
|
}
|
||
|
|
||
|
// 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>) -> 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 = "<i>"
|
||
|
let italicEnd = "</i>"
|
||
|
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<String>, 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 = "<a href='\(userNameHrefString)'><img src='\(Self.botIconName)' style='margin: 0em .2em .35em .1em; width: 1em' />"
|
||
|
} else {
|
||
|
linkStartInsert = "<a href='\(userNameHrefString)'>"
|
||
|
}
|
||
|
let linkEndInsert = "</a>"
|
||
|
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 "<img src='\(Self.anonymousIconName)' style='margin: 0em .2em .35em .1em; width: 1em' />\(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)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
}
|