Skip to content

Commit 7bba56b

Browse files
authored
Add support for push preferences (#3820)
* Add Push Preference Payloads and Endpoint * Add domain models and `CurrentUserController.setUserPushPreferences` * Add user profile push preference UI to the Demo App * Fix parsing push preference response * Improve the interface of the current user push preference * Add disablePushNotifications to CurrentUserController * Fix channel preferences payload parsing * Add push preference feature to channel controller * Fix push preference payload parsing when channel is empty * Fix nested hell parsing for channel preferences * Reuse preferences view for both channel and user preferences * Improve the UX of the PushPreferencesView * Make the parsing of channel preferences easier to read * Merge UserPushPreference and ChannelPushPreference into one * Update Demo App min version * Add push preference to both Channel and CurrentUser models * Add initial data to user preferences view * Fix PushPreferencesDTO not creating in the local DB * Remove top right save button in preferences view * Trigger currentUser and channel updates when push pref is changed * Remove userId from the Request Payload since the backend automatically uses the current user * Change the name from disablePushNotifications to snoozePushNotifications * Change button name to Snooze Notifications * Review changes * Add payload parsing test coverage * Fix existing tests so that it compiles * Add endpoint test coverage * Add ChannelDTO and CurrentUserDTO test coverage * Add test coverage to controllers and updaters * Fix push preference payload tests * Update CHANGELOG.md * Update CHANGELOG.md * Fix failing new tests
1 parent d3724f9 commit 7bba56b

File tree

51 files changed

+1872
-275
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1872
-275
lines changed

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
33

44
# Upcoming
55

6-
### 🔄 Changed
6+
## StreamChat
7+
### ✅ Added
8+
- Add support for push preferences [#3820](https://github.com/GetStream/stream-chat-swift/pull/3820)
9+
- Add `CurrentChatUserController.setPushPreference(level:)`
10+
- Add `CurrentChatUserController.snoozePushNotifications(until:)`
11+
- Add `ChatChannelController.setPushPreference(level:)`
12+
- Add `ChatChannelController.snoozePushNotifications(until:)`
713

814
# [4.89.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.89.0)
915
_September 22, 2025_
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import StreamChat
6+
import SwiftUI
7+
8+
@available(iOS 15, *)
9+
struct PushPreferencesView: View {
10+
let onSetPreferences: (PushPreferenceLevel, @escaping (Result<PushPreferenceLevel, Error>) -> Void) -> Void
11+
let onDisableNotifications: (Date, @escaping (Result<PushPreferenceLevel, Error>) -> Void) -> Void
12+
let onDismiss: () -> Void
13+
let initialPreference: PushPreference?
14+
15+
@State private var selectedLevel: PushPreferenceLevel
16+
@State private var disableUntil: Date?
17+
@State private var isLoading = false
18+
@State private var showSuccessMessage = false
19+
@State private var errorMessage: String?
20+
@State private var showDatePicker = false
21+
22+
private let dateFormatter: DateFormatter = {
23+
let formatter = DateFormatter()
24+
formatter.dateStyle = .medium
25+
formatter.timeStyle = .short
26+
return formatter
27+
}()
28+
29+
init(
30+
onSetPreferences: @escaping (PushPreferenceLevel, @escaping (Result<PushPreferenceLevel, Error>) -> Void) -> Void,
31+
onDisableNotifications: @escaping (Date, @escaping (Result<PushPreferenceLevel, Error>) -> Void) -> Void,
32+
onDismiss: @escaping () -> Void,
33+
initialPreference: PushPreference? = nil
34+
) {
35+
self.onSetPreferences = onSetPreferences
36+
self.onDisableNotifications = onDisableNotifications
37+
self.onDismiss = onDismiss
38+
self.initialPreference = initialPreference
39+
40+
// Initialize state based on the initial preference
41+
_selectedLevel = State(initialValue: initialPreference?.level ?? .all)
42+
43+
// Only set disableUntil if the date is in the future
44+
let disableUntilDate = initialPreference?.disabledUntil
45+
if let date = disableUntilDate, date > Date() {
46+
_disableUntil = State(initialValue: date)
47+
} else {
48+
_disableUntil = State(initialValue: nil)
49+
}
50+
}
51+
52+
var body: some View {
53+
NavigationView {
54+
Form {
55+
Section(header: Text("Notification Level")) {
56+
ForEach([PushPreferenceLevel.all, .mentions, .none], id: \.rawValue) { level in
57+
HStack {
58+
VStack(alignment: .leading, spacing: 4) {
59+
Text(levelTitle(for: level))
60+
.font(.headline)
61+
Text(levelDescription(for: level))
62+
.font(.caption)
63+
.foregroundColor(.secondary)
64+
}
65+
66+
Spacer()
67+
68+
if selectedLevel == level {
69+
Image(systemName: "checkmark.circle.fill")
70+
.foregroundColor(.blue)
71+
}
72+
}
73+
.contentShape(Rectangle())
74+
.onTapGesture {
75+
if disableUntil == nil {
76+
selectedLevel = level
77+
}
78+
}
79+
.disabled(disableUntil != nil)
80+
.opacity(disableUntil != nil ? 0.5 : 1.0)
81+
}
82+
}
83+
84+
Section(header: Text("Temporary Disable")) {
85+
Toggle("Disable notifications temporarily", isOn: Binding(
86+
get: { disableUntil != nil },
87+
set: { isEnabled in
88+
if isEnabled {
89+
// Set to 1 hour from now by default
90+
disableUntil = Date().addingTimeInterval(3600)
91+
} else {
92+
disableUntil = nil
93+
}
94+
}
95+
))
96+
97+
if let disableUntil = disableUntil {
98+
HStack {
99+
Text("Disable until:")
100+
Spacer()
101+
Button(dateFormatter.string(from: disableUntil)) {
102+
showDatePicker = true
103+
}
104+
.foregroundColor(.blue)
105+
}
106+
}
107+
}
108+
109+
Section {
110+
if disableUntil != nil {
111+
Button(action: disableNotifications) {
112+
HStack {
113+
if isLoading {
114+
ProgressView()
115+
.scaleEffect(0.8)
116+
} else {
117+
Image(systemName: "bell.slash")
118+
}
119+
Text("Snooze Notifications")
120+
}
121+
.frame(maxWidth: .infinity)
122+
}
123+
.disabled(isLoading)
124+
.foregroundColor(.white)
125+
.listRowBackground(Color.orange)
126+
} else {
127+
Button(action: savePreferences) {
128+
HStack {
129+
if isLoading {
130+
ProgressView()
131+
.scaleEffect(0.8)
132+
} else {
133+
Image(systemName: "checkmark.circle")
134+
}
135+
Text("Save Preferences")
136+
}
137+
.frame(maxWidth: .infinity)
138+
}
139+
.disabled(isLoading)
140+
.foregroundColor(.white)
141+
.listRowBackground(Color.blue)
142+
}
143+
}
144+
145+
if let errorMessage = errorMessage {
146+
Section {
147+
HStack {
148+
Image(systemName: "exclamationmark.triangle.fill")
149+
.foregroundColor(.red)
150+
Text(errorMessage)
151+
.foregroundColor(.red)
152+
}
153+
}
154+
}
155+
}
156+
.navigationTitle("Push Preferences")
157+
.navigationBarTitleDisplayMode(.inline)
158+
.navigationBarItems(
159+
leading: Button("Cancel") {
160+
onDismiss()
161+
}
162+
.disabled(isLoading)
163+
)
164+
.sheet(isPresented: $showDatePicker) {
165+
NavigationView {
166+
DatePicker(
167+
"Disable until",
168+
selection: Binding(
169+
get: { disableUntil ?? Date() },
170+
set: { disableUntil = $0 }
171+
),
172+
in: Date()...,
173+
displayedComponents: [.date, .hourAndMinute]
174+
)
175+
.datePickerStyle(.wheel)
176+
.navigationTitle("Select Date")
177+
.navigationBarTitleDisplayMode(.inline)
178+
.navigationBarItems(
179+
leading: Button("Cancel") {
180+
showDatePicker = false
181+
},
182+
trailing: Button("Done") {
183+
showDatePicker = false
184+
}
185+
)
186+
}
187+
}
188+
.alert("Success", isPresented: $showSuccessMessage) {
189+
Button("OK") {
190+
onDismiss()
191+
}
192+
} message: {
193+
Text("Push preferences have been updated successfully.")
194+
}
195+
}
196+
}
197+
198+
private func levelTitle(for level: PushPreferenceLevel) -> String {
199+
switch level {
200+
case .all:
201+
return "All Notifications"
202+
case .mentions:
203+
return "Mentions Only"
204+
case .none:
205+
return "No Notifications"
206+
default:
207+
return level.rawValue.capitalized
208+
}
209+
}
210+
211+
private func levelDescription(for level: PushPreferenceLevel) -> String {
212+
switch level {
213+
case .all:
214+
return "Receive notifications for all messages"
215+
case .mentions:
216+
return "Only receive notifications when mentioned"
217+
case .none:
218+
return "Disable all push notifications"
219+
default:
220+
return "Custom notification level"
221+
}
222+
}
223+
224+
private func savePreferences() {
225+
isLoading = true
226+
errorMessage = nil
227+
228+
onSetPreferences(selectedLevel) { result in
229+
DispatchQueue.main.async {
230+
isLoading = false
231+
232+
switch result {
233+
case .success:
234+
showSuccessMessage = true
235+
case .failure(let error):
236+
errorMessage = error.localizedDescription
237+
}
238+
}
239+
}
240+
}
241+
242+
private func disableNotifications() {
243+
guard let disableUntil = disableUntil else { return }
244+
245+
isLoading = true
246+
errorMessage = nil
247+
248+
onDisableNotifications(disableUntil) { result in
249+
DispatchQueue.main.async {
250+
isLoading = false
251+
252+
switch result {
253+
case .success:
254+
// Dismiss the screen immediately when disabling notifications
255+
onDismiss()
256+
case .failure(let error):
257+
errorMessage = error.localizedDescription
258+
}
259+
}
260+
}
261+
}
262+
}

0 commit comments

Comments
 (0)