| 1 | import Foundation |
| 2 | |
| 3 | // Attachment display helpers β exact mirrors of the web's (ui.js fileIcon / |
| 4 | // format.js formatBytes / chat.js formatDuration), so a file reads identically |
| 5 | // in both feeds. Keep in lockstep with the JS; these are copy targets, not |
| 6 | // places to improve formatting. |
| 7 | |
| 8 | // ui.js FILE_TYPES β the allowed non-image extensions and their emoji. |
| 9 | private let fileIcons: [String: String] = [ |
| 10 | "pdf": "π", |
| 11 | "txt": "π", "md": "π", "markdown": "π", "json": "π", |
| 12 | "csv": "π", "xls": "π", "xlsx": "π", |
| 13 | "doc": "π", "docx": "π", |
| 14 | "ppt": "π", "pptx": "π", |
| 15 | ] |
| 16 | |
| 17 | /// A document emoji for an attachment, by extension. π for anything unmapped. |
| 18 | public func fileIcon(_ name: String?) -> String { |
| 19 | return fileIcons[fileExt(name)] ?? "π" |
| 20 | } |
| 21 | |
| 22 | private func fileExt(_ name: String?) -> String { |
| 23 | (name ?? "").split(separator: ".").last.map { $0.lowercased() } ?? "" |
| 24 | } |
| 25 | |
| 26 | /// ui.js fileAllowed: any file may be attached (2026-07-09 β the extension |
| 27 | /// allowlist bit real files, e.g. extensionless text; the server only stores |
| 28 | /// an encrypted blob, so the gate bought nothing). The icon map above is the |
| 29 | /// only per-type behaviour left. Kept so call sites stay in lockstep with |
| 30 | /// the web's fileAllowed. |
| 31 | public func fileAllowed(_ name: String?) -> Bool { |
| 32 | true |
| 33 | } |
| 34 | |
| 35 | /// "742 B" / "1.4 KB" / "12 MB" β format.js formatBytes. |
| 36 | public func formatBytes(_ n: Int64) -> String { |
| 37 | guard n >= 0 else { return "" } |
| 38 | if n < 1024 { return "\(n) B" } |
| 39 | let units = ["KB", "MB", "GB"] |
| 40 | var v = Double(n) / 1024 |
| 41 | var i = 0 |
| 42 | while v >= 1024 && i < units.count - 1 { |
| 43 | v /= 1024 |
| 44 | i += 1 |
| 45 | } |
| 46 | let s = v < 10 ? String(format: "%.1f", v) : String(Int(v.rounded())) |
| 47 | return "\(s) \(units[i])" |
| 48 | } |
| 49 | |
| 50 | /// format.js replySnippet: the one-line quote of a payload β its text collapsed |
| 51 | /// and clamped, else "π· Photo" / "π name" from its first attachment. |
| 52 | public func replySnippet(_ payload: MessagePayload, max: Int = 140) -> String { |
| 53 | func clamp(_ s: String) -> String { |
| 54 | s.count > max ? String(s.prefix(max - 1)).trimmingCharacters(in: .whitespaces) + "β¦" : s |
| 55 | } |
| 56 | let text = (payload.text ?? "") |
| 57 | .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) |
| 58 | .trimmingCharacters(in: .whitespaces) |
| 59 | if !text.isEmpty { return clamp(text) } |
| 60 | if let a = payload.attachments?.first { |
| 61 | if a.isImage { return "π· Photo" } |
| 62 | return clamp("π " + (a.name ?? "Attachment")) |
| 63 | } |
| 64 | return "" |
| 65 | } |
| 66 | |
| 67 | /// format.js reactionLine: the sidebar line for a reaction event β |
| 68 | /// "James reacted π to βbuy milkβ". A reaction has no text of its own, so |
| 69 | /// without this the row went blank. `who` is resolved by the caller so it can |
| 70 | /// say "You"; the quoted snippet is the payload's `targetText`, absent on |
| 71 | /// reactions from older clients (then the bare form stands on its own). |
| 72 | public func reactionLine(_ who: String, _ payload: MessagePayload, max: Int = 40) -> String { |
| 73 | let actor = who.trimmingCharacters(in: .whitespaces).isEmpty ? "Someone" : who |
| 74 | let emoji = (payload.emoji ?? "").trimmingCharacters(in: .whitespaces) |
| 75 | // on == false is an un-react. It still speaks: on reload this event is all |
| 76 | // the list has, so silence here would mean a different row after a refresh. |
| 77 | let off = payload.on == false |
| 78 | let head = "\(actor) \(off ? "removed" : "reacted")" + (emoji.isEmpty ? "" : " \(emoji)") |
| 79 | var quoted = MessagePayload() |
| 80 | quoted.text = payload.targetText |
| 81 | let snip = replySnippet(quoted, max: max) |
| 82 | return snip.isEmpty ? head : "\(head) \(off ? "from" : "to") β\(snip)β" |
| 83 | } |
| 84 | |
| 85 | /// format.js typingSummary: who's typing, as a subtle one-liner that never |
| 86 | /// grows unbounded past two names. |
| 87 | public func typingSummary(_ names: [String]) -> String { |
| 88 | let list = names.filter { !$0.isEmpty } |
| 89 | if list.isEmpty { return "" } |
| 90 | if list.count == 1 { return "\(list[0]) is typingβ¦" } |
| 91 | if list.count == 2 { return "\(list[0]) and \(list[1]) are typingβ¦" } |
| 92 | return "Several people are typingβ¦" |
| 93 | } |
| 94 | |
| 95 | // ---- feed time separators (chat.js viewItems/dayLabel/formatTime) --------- |
| 96 | |
| 97 | /// chat.js formatTime: the locale short time ("0:01", "14:32"). |
| 98 | public func chatTime(_ seconds: Int64) -> String { |
| 99 | Date(timeIntervalSince1970: TimeInterval(seconds)) |
| 100 | .formatted(.dateTime.hour(.defaultDigits(amPM: .abbreviated)).minute(.twoDigits)) |
| 101 | } |
| 102 | |
| 103 | /// chat.js dayLabel: "Today" / "Yesterday" / a weekday (past week) / a date. |
| 104 | public func chatDayLabel(_ seconds: Int64, now: Date = Date()) -> String { |
| 105 | let date = Date(timeIntervalSince1970: TimeInterval(seconds)) |
| 106 | let cal = Calendar.current |
| 107 | if cal.isDate(date, inSameDayAs: now) { return "Today" } |
| 108 | if cal.isDate(date, inSameDayAs: now.addingTimeInterval(-86400)) { return "Yesterday" } |
| 109 | if now.timeIntervalSince(date) < 7 * 86400 { |
| 110 | return date.formatted(.dateTime.weekday(.wide)) |
| 111 | } |
| 112 | let sameYear = cal.component(.year, from: date) == cal.component(.year, from: now) |
| 113 | return date.formatted(sameYear ? .dateTime.month(.abbreviated).day() |
| 114 | : .dateTime.year().month(.abbreviated).day()) |
| 115 | } |
| 116 | |
| 117 | /// The web's separator rule (TIME_SEPARATOR_GAP_MS): a message opens the feed |
| 118 | /// or follows a day change β dated separator; >15 min gap β time-only; else none. |
| 119 | public enum TimeSeparator: Equatable { |
| 120 | case dated |
| 121 | case timeOnly |
| 122 | } |
| 123 | |
| 124 | public func timeSeparator(at seconds: Int64, previous: Int64?) -> TimeSeparator? { |
| 125 | guard let previous else { return .dated } |
| 126 | let cal = Calendar.current |
| 127 | let newDay = !cal.isDate(Date(timeIntervalSince1970: TimeInterval(seconds)), |
| 128 | inSameDayAs: Date(timeIntervalSince1970: TimeInterval(previous))) |
| 129 | if newDay { return .dated } |
| 130 | if seconds - previous > 15 * 60 { return .timeOnly } |
| 131 | return nil |
| 132 | } |
| 133 | |
| 134 | /// "0:42" / "3:07" / "1:02:09" β chat.js formatDuration. |
| 135 | public func formatDuration(_ secs: Double) -> String { |
| 136 | let t = max(0, Int(secs.rounded())) |
| 137 | let h = t / 3600, m = (t % 3600) / 60, s = t % 60 |
| 138 | let mm = h > 0 ? String(format: "%02d", m) : String(m) |
| 139 | let ss = String(format: "%02d", s) |
| 140 | return h > 0 ? "\(h):\(mm):\(ss)" : "\(mm):\(ss)" |
| 141 | } |
| 142 | |