eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import 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.
9private 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.
18public func fileIcon(_ name: String?) -> String {
19 return fileIcons[fileExt(name)] ?? "πŸ“Ž"
20}
21
22private 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.
31public func fileAllowed(_ name: String?) -> Bool {
32 true
33}
34
35/// "742 B" / "1.4 KB" / "12 MB" β€” format.js formatBytes.
36public 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.
52public 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).
72public 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.
87public 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").
98public 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.
104public 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.
119public enum TimeSeparator: Equatable {
120 case dated
121 case timeOnly
122}
123
124public 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.
135public 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