Skip to content
This repository was archived by the owner on May 8, 2026. It is now read-only.

Commit 33ed309

Browse files
committed
feat: discussion at a glance
closes #129
1 parent 5ba1935 commit 33ed309

8 files changed

Lines changed: 608 additions & 1 deletion

File tree

Hax/Extensions/UserDefaultsExtension.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ extension UserDefaults {
1111

1212
enum Key {
1313
static let defaultFeed = "defaultFeed"
14+
static let discussionAtAGlanceIsEnabled = "discussionAtAGlanceIsEnabled"
1415
static let numberOfLaunches = "numberOfLaunches"
1516
static let readItems = "readItems"
1617
static let reviewHasBeenRequested = "reviewHasBeenRequested"
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
//
2+
// DiscussionAtAGlanceViewModel.swift
3+
// Hax
4+
//
5+
// Created by Luis Fariña on 9/12/25.
6+
//
7+
8+
import Foundation
9+
import FoundationModels
10+
11+
enum DiscussionAtAGlanceViewState: Equatable {
12+
13+
// MARK: Cases
14+
15+
case tapToGenerate
16+
case generating
17+
case generated(summary: String?)
18+
}
19+
20+
@MainActor
21+
protocol DiscussionAtAGlanceViewModelProtocol: Observable {
22+
23+
// MARK: Properties
24+
25+
var state: DiscussionAtAGlanceViewState { get }
26+
27+
// MARK: Methods
28+
29+
func onTap() async
30+
}
31+
32+
@Observable
33+
final class DiscussionAtAGlanceViewModel: DiscussionAtAGlanceViewModelProtocol {
34+
35+
// MARK: Properties
36+
37+
private(set) var state: DiscussionAtAGlanceViewState = .tapToGenerate
38+
private let item: Item
39+
private let minimumNumberOfComments = 2
40+
41+
// MARK: Initialization
42+
43+
init?(item: Item) {
44+
guard
45+
#available(iOS 26.0, *),
46+
SystemLanguageModel.default.isAvailable,
47+
item
48+
.comments
49+
.lazy
50+
.filter({ $0.depth == .zero })
51+
.prefix(minimumNumberOfComments)
52+
.count == minimumNumberOfComments
53+
else {
54+
return nil
55+
}
56+
57+
self.item = item
58+
}
59+
60+
// MARK: Methods
61+
62+
func onTap() async {
63+
guard
64+
#available(iOS 26.0, *),
65+
state == .tapToGenerate
66+
else {
67+
return
68+
}
69+
70+
state = .generating
71+
72+
let session = LanguageModelSession(
73+
instructions: instructions
74+
)
75+
76+
let prompt = (
77+
[
78+
item.title.map {
79+
"[Title] \($0)"
80+
},
81+
item.body.map {
82+
"[Description] \($0)"
83+
}
84+
] +
85+
item
86+
.comments
87+
.filter { $0.depth == .zero }
88+
.prefix(10)
89+
.compactMap {
90+
$0.item.body.map {
91+
"[Comment] \($0)"
92+
}
93+
}
94+
)
95+
.compacted()
96+
.joined(separator: "\n")
97+
98+
do {
99+
for try await response in session.streamResponse(
100+
to: prompt
101+
) {
102+
state = .generated(summary: response.content)
103+
}
104+
} catch {
105+
state = .generated(summary: nil)
106+
}
107+
}
108+
}
109+
110+
// MARK: - Private extension
111+
112+
private extension DiscussionAtAGlanceViewModel {
113+
114+
// MARK: Properties
115+
116+
var instructions: String {
117+
let locale = Locale.current
118+
119+
var instructions = """
120+
Summarize the comments of a Hacker News story provided in \
121+
the prompt. Do not quote comments directly and do not refer \
122+
to the order of the comments (e.g., "first comment", \
123+
"second comment"). Do not use enumerations, lists, or \
124+
numbering. When writing the summary, do not mention that \
125+
the comments are from Hacker News. Focus only on what \
126+
people discuss in the comments, not on the story itself. \
127+
Respond as briefly as possible.
128+
"""
129+
130+
instructions += {
131+
if Locale.Language(identifier: "en_US").isEquivalent(
132+
to: locale.language
133+
) {
134+
""
135+
} else {
136+
"The person's locale is \(locale.identifier)."
137+
}
138+
}()
139+
140+
return instructions
141+
}
142+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
//
2+
// DiscussionAtAGlanceView.swift
3+
// Hax
4+
//
5+
// Created by Luis Fariña on 9/12/25.
6+
//
7+
8+
import SwiftUI
9+
10+
struct DiscussionAtAGlanceView<
11+
Model: DiscussionAtAGlanceViewModelProtocol
12+
>: View {
13+
14+
// MARK: Properties
15+
16+
@State var model: Model
17+
18+
// MARK: Body
19+
20+
var body: some View {
21+
VStack(alignment: .leading, spacing: 10) {
22+
Group {
23+
HStack(spacing: 5) {
24+
Image(systemName: "apple.intelligence")
25+
Text("Discussion at a glance")
26+
.bold()
27+
.textCase(.uppercase)
28+
}
29+
30+
if model.state == .tapToGenerate {
31+
Text(
32+
"""
33+
Tap to generate a summary of the story's \
34+
top comments
35+
"""
36+
)
37+
} else if model.state == .generating {
38+
Text("Generating summary…")
39+
}
40+
}
41+
.foregroundStyle(.secondary)
42+
43+
if case .generated(let summary) = model.state {
44+
if let summary {
45+
Text(LocalizedStringKey(summary))
46+
} else {
47+
Text(
48+
"There was an error generating the summary."
49+
)
50+
.foregroundStyle(.secondary)
51+
}
52+
}
53+
}
54+
.animation(.default, value: model.state)
55+
.font(.footnote)
56+
.listRowBackground(listRowBackground)
57+
.listRowSeparator(.hidden)
58+
.frame(maxWidth: .infinity, alignment: .leading)
59+
.contentShape(Rectangle())
60+
.onTapGesture {
61+
Task {
62+
await model.onTap()
63+
}
64+
}
65+
}
66+
}
67+
68+
// MARK: - Private extension
69+
70+
private extension DiscussionAtAGlanceView {
71+
72+
// MARK: Properties
73+
74+
var listRowBackground: some View {
75+
AngularGradient(
76+
gradient: Gradient(
77+
colors: [
78+
Color(
79+
red: .zero,
80+
green: 0.5,
81+
blue: 1,
82+
opacity: 0.8
83+
),
84+
Color(
85+
red: 0.5,
86+
green: .zero,
87+
blue: 1,
88+
opacity: 0.7
89+
),
90+
Color(
91+
red: 1,
92+
green: .zero,
93+
blue: 0.5,
94+
opacity: 0.6
95+
),
96+
Color(
97+
red: 1,
98+
green: 0.5,
99+
blue: .zero,
100+
opacity: 0.8
101+
),
102+
Color(
103+
red: .zero,
104+
green: 0.5,
105+
blue: 1,
106+
opacity: 0.8
107+
)
108+
]
109+
),
110+
center: .center
111+
)
112+
.blur(radius: 50)
113+
.overlay(Color(UIColor.systemBackground).opacity(0.25))
114+
.clipped()
115+
}
116+
}
117+
118+
#if DEBUG
119+
120+
// MARK: - Previews
121+
122+
// MARK: Types
123+
124+
private struct PreviewDiscussionAtAGlanceViewModel: DiscussionAtAGlanceViewModelProtocol {
125+
126+
// MARK: Properties
127+
128+
let state: DiscussionAtAGlanceViewState
129+
130+
// MARK: Methods
131+
132+
func onTap() async {
133+
// Do nothing
134+
}
135+
}
136+
137+
// MARK: Previews
138+
139+
@available(iOS 26.0, *)
140+
#Preview {
141+
List {
142+
DiscussionAtAGlanceView(
143+
model: PreviewDiscussionAtAGlanceViewModel(
144+
state: .tapToGenerate
145+
)
146+
)
147+
Spacer()
148+
DiscussionAtAGlanceView(
149+
model: PreviewDiscussionAtAGlanceViewModel(
150+
state: .generating
151+
)
152+
)
153+
Spacer()
154+
DiscussionAtAGlanceView(
155+
model: PreviewDiscussionAtAGlanceViewModel(
156+
state: .generated(
157+
summary: """
158+
Lorem ipsum dolor sit amet, consectetur \
159+
adipiscing elit. Aliquam suscipit ullamcorper \
160+
lacus, in fringilla nulla sagittis eget. Ut \
161+
pretium semper ligula, non vestibulum neque \
162+
posuere sit amet. Quisque ut eros finibus, \
163+
dictum orci vel, vestibulum neque.
164+
"""
165+
)
166+
)
167+
)
168+
Spacer()
169+
DiscussionAtAGlanceView(
170+
model: PreviewDiscussionAtAGlanceViewModel(
171+
state: .generated(summary: nil)
172+
)
173+
)
174+
}
175+
.listStyle(.plain)
176+
}
177+
178+
#endif

Hax/Presentation/Screens/View Models/ItemViewModel.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ protocol ItemViewModelProtocol: ObservableObject {
3636
/// The identifier of the comment to be highlighted.
3737
var highlightedCommentId: Int? { get }
3838

39+
/// The model of the "discussion at a glance" view.
40+
var discussionAtAGlanceViewModel: DiscussionAtAGlanceViewModel? { get }
41+
3942
/// The title for the view.
4043
var title: String { get }
4144

@@ -69,6 +72,7 @@ class ItemViewModel: ItemViewModelProtocol {
6972
@Published var url: IdentifiableURL?
7073
@Published var user: IdentifiableString?
7174
@Published private(set) var highlightedCommentId: Int?
75+
@Published private(set) var discussionAtAGlanceViewModel: DiscussionAtAGlanceViewModel?
7276

7377
var title: String {
7478
guard let descendants = item.descendants else {
@@ -211,6 +215,9 @@ private extension ItemViewModel {
211215
} else {
212216
self.item = item
213217
comments = item.comments
218+
discussionAtAGlanceViewModel = DiscussionAtAGlanceViewModel(
219+
item: item
220+
)
214221
}
215222

216223
self.comments = comments

0 commit comments

Comments
 (0)