-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathEmojiReactionPicker.swift
More file actions
146 lines (137 loc) · 5.86 KB
/
Copy pathEmojiReactionPicker.swift
File metadata and controls
146 lines (137 loc) · 5.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import SwiftUI
/// Compact reaction picker shown as a popover when the user taps the heart on a
/// post card.
///
/// Renders up to 18 of the user's frequency-sorted quick-reactions as a fixed
/// 6-column grid (no scrolling), followed by a pinned "More reactions" footer
/// that always opens the full `EmojiLibrarySheet`. Long-press a cell to remove
/// that emoji from the quick list. Tapping a cell invokes `onSelect(_:)`; the
/// footer row fires `onPlus()`. The parent owns dismissal in both cases.
struct EmojiReactionPicker: View {
@State private var emojiRepo = EmojiRepository.shared
@ObservedObject private var emojiCache = EmojiImageCache.shared
/// Picker keys (unicode chars or `:shortcode:`) the user has already reacted
/// with on this post. These cells are highlighted; tapping one signals the
/// parent to remove that reaction rather than add a duplicate.
var reactedKeys: Set<String> = []
let onSelect: (PickedEmoji) -> Void
let onPlus: () -> Void
private let cellSize: CGFloat = 36
private let columns: Int = 6
private let maxVisible: Int = 18 // 3 rows × 6 columns
/// Quick reactions, plus any of the user's existing reactions that aren't
/// already shown — so a reaction can always be found and removed even if it
/// has dropped out of the top-N quick list.
private var displayEntries: [String] {
var entries = Array(emojiRepo.sortedQuickReactions.prefix(maxVisible))
for key in reactedKeys where !entries.contains(key) { entries.append(key) }
return entries
}
var body: some View {
let entries = displayEntries
let grid = Array(repeating: GridItem(.fixed(cellSize), spacing: 8), count: columns)
VStack(alignment: .leading, spacing: 0) {
LazyVGrid(columns: grid, spacing: 8) {
ForEach(entries, id: \.self) { key in
cell(for: key)
}
}
.padding(12)
Button { onPlus() } label: {
HStack(spacing: 6) {
Image(systemName: "plus.circle")
.font(.system(size: 15))
Text("More reactions")
.font(.subheadline)
}
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.frame(height: 36)
// Without an explicit content shape, the button's tap target
// is just the glyph + label bounds. The full-width frame is
// visual only — taps in the blank space on either side were
// missed. Filling the row's hit area makes "More reactions"
// the easiest target in the picker, matching what the user
// most often wants from this popover.
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.frame(width: CGFloat(columns) * cellSize + CGFloat(columns - 1) * 8 + 24)
.onAppear {
for key in entries {
if key.hasPrefix(":") && key.hasSuffix(":") {
let sc = String(key.dropFirst().dropLast())
if let url = emojiRepo.resolvedCustomMap[sc] {
emojiCache.ensureLoaded(url)
}
}
}
}
}
@ViewBuilder
private func cell(for key: String) -> some View {
Button {
if let picked = pickedEmoji(for: key) {
onSelect(picked)
}
} label: {
ZStack {
if key.hasPrefix(":") && key.hasSuffix(":") {
customCell(shortcode: String(key.dropFirst().dropLast()))
} else {
Text(key)
.font(.system(size: 26))
}
}
.frame(width: cellSize, height: cellSize)
.background(
// Highlight reactions the user has already placed — tapping one
// removes it.
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.accentColor.opacity(reactedKeys.contains(key) ? 0.18 : 0))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Color.accentColor.opacity(reactedKeys.contains(key) ? 0.5 : 0), lineWidth: 1)
)
)
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
emojiRepo.removeFromQuickList(key)
} label: {
Label("Remove from quick reactions", systemImage: "minus.circle")
}
}
}
@ViewBuilder
private func customCell(shortcode: String) -> some View {
if let url = emojiRepo.resolvedCustomMap[shortcode],
let img = emojiCache.image(for: url) {
Image(uiImage: img)
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fit)
.frame(width: cellSize - 4, height: cellSize - 4)
} else if let url = emojiRepo.resolvedCustomMap[shortcode] {
Color.clear
.frame(width: cellSize - 4, height: cellSize - 4)
.onAppear { emojiCache.ensureLoaded(url) }
} else {
Text(":\(shortcode):")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
private func pickedEmoji(for key: String) -> PickedEmoji? {
if key.hasPrefix(":") && key.hasSuffix(":") {
let sc = String(key.dropFirst().dropLast())
guard let url = emojiRepo.resolvedCustomMap[sc] else { return nil }
return .custom(shortcode: sc, url: url)
}
return .unicode(key)
}
}