diff --git a/DevLog/Presentation/Structure/PushNotificationItem.swift b/DevLog/Presentation/Structure/PushNotificationItem.swift index dd8273fb..5857f99c 100644 --- a/DevLog/Presentation/Structure/PushNotificationItem.swift +++ b/DevLog/Presentation/Structure/PushNotificationItem.swift @@ -9,6 +9,7 @@ import Foundation struct PushNotificationItem: Identifiable, Hashable { let id: String + var isHidden = false let title: String let body: String let receivedAt: Date diff --git a/DevLog/Presentation/Structure/Todo/TodoListItem.swift b/DevLog/Presentation/Structure/Todo/TodoListItem.swift index ee8717d1..dc958b0c 100644 --- a/DevLog/Presentation/Structure/Todo/TodoListItem.swift +++ b/DevLog/Presentation/Structure/Todo/TodoListItem.swift @@ -9,6 +9,7 @@ import Foundation struct TodoListItem: Identifiable, Hashable { let id: String + var isHidden = false let number: Int let title: String let tags: [String] diff --git a/DevLog/Presentation/Structure/WebPageItem.swift b/DevLog/Presentation/Structure/WebPageItem.swift index 9cb403d5..9f19f654 100644 --- a/DevLog/Presentation/Structure/WebPageItem.swift +++ b/DevLog/Presentation/Structure/WebPageItem.swift @@ -9,6 +9,7 @@ import SwiftUI struct WebPageItem: Identifiable, Hashable { private let metadata: WebPage + var isHidden = false init(from metadata: WebPage) { self.metadata = metadata diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index e3a5f39d..bb682889 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -14,6 +14,7 @@ final class HomeViewModel: Store { var preferences: [TodoCategoryItem] = [] var recentTodos: [RecentTodoItem] = [] var webPages: [WebPageItem] = [] + var needsWebPageRefresh = false var isNetworkConnected: Bool = true var showContentPicker: Bool = false var showTodoEditor: Bool = false @@ -40,7 +41,10 @@ final class HomeViewModel: Store { case setPresentation(Presentation, Bool) case setAlert(isPresented: Bool, type: AlertType? = nil) case setToast(isPresented: Bool, type: ToastType? = nil) + case refreshWebPages case setLoading(LoadingTarget, Bool) + case setWebPageHidden(URL, Bool) + case handleWebPageDeleteFailure(URL) case tapTodoCategory(TodoCategory) case orderTodoCategory([TodoCategoryItem]) case setTodoCategory([TodoCategoryItem]) @@ -51,13 +55,12 @@ final class HomeViewModel: Store { case deleteWebPage(WebPageItem) case undoDeleteWebPage case updateWebPages([WebPageItem]) - case restoreWebPage(WebPageItem, Int) } enum SideEffect { case addTodo(Todo) case addWebPage(String) - case deleteWebPage(WebPageItem, Int) + case deleteWebPage(WebPageItem) case undoDeleteWebPage(String) case fetchTodoCategoryPreferences case updateTodoCategoryPreferences([TodoCategoryItem]) @@ -140,13 +143,13 @@ final class HomeViewModel: Store { switch action { case .networkStatusChanged(let isConnected): state.isNetworkConnected = isConnected - case .onAppear, .setPresentation, .setAlert, .setToast, .tapTodoCategory, - .orderTodoCategory, .addTodo, .updateWebPageURLInput, + case .onAppear, .setPresentation, .setAlert, .setToast, .refreshWebPages, + .tapTodoCategory, .orderTodoCategory, .addTodo, .updateWebPageURLInput, .addWebPage, .deleteWebPage, .undoDeleteWebPage: effects = reduceByView(action, state: &state) - case .setLoading, .setTodoCategory, .updateRecentTodos, - .updateWebPages, .restoreWebPage: + case .setLoading, .setWebPageHidden, .handleWebPageDeleteFailure, .setTodoCategory, + .updateRecentTodos, .updateWebPages: effects = reduceByRun(action, state: &state) } @@ -218,38 +221,24 @@ final class HomeViewModel: Store { send(.setAlert(isPresented: true, type: .error)) } } - case .deleteWebPage(let page, let index): - beginLoading(for: .webPage, mode: .delayed) + case .deleteWebPage(let page): Task { do { - defer { endLoading(for: .webPage, mode: .delayed) } try await deleteWebPageUseCase.execute(page.url.absoluteString) } catch { - send(.restoreWebPage(page, index)) + send(.handleWebPageDeleteFailure(page.id)) send(.setAlert(isPresented: true, type: .error)) } } case .undoDeleteWebPage(let urlString): - beginLoading(for: .webPage, mode: .delayed) Task { - defer { endLoading(for: .webPage, mode: .delayed) } - - var shouldPresentError = false - do { try await undoDeleteWebPageUseCase.execute(urlString) + try await addWebPageUseCase.execute(urlString) } catch { - shouldPresentError = true - } - - do { - let pages = try await fetchWebPagesUseCase.execute("") - send(.updateWebPages(pages.map { WebPageItem(from: $0) })) - } catch { - shouldPresentError = true - } - - if shouldPresentError { + if let webPageURL = URL(string: urlString) { + send(.setWebPageHidden(webPageURL, true)) + } send(.setAlert(isPresented: true, type: .error)) } } @@ -285,6 +274,8 @@ private extension HomeViewModel { switch action { case .onAppear: return [.fetchTodoCategoryPreferences, .fetchRecentTodos, .fetchWebPages] + case .refreshWebPages: + return [.fetchWebPages] case .setPresentation(let presentation, let isPresented): setPresentation(&state, presentation: presentation, isPresented: isPresented) case .setAlert(let presented, let type): @@ -296,6 +287,7 @@ private extension HomeViewModel { case .setToast(let isPresented, let type): setToast(&state, isPresented: isPresented, for: type) if !isPresented { + state.webPages.removeAll { $0.isHidden } deletedWebPageURLString = nil } case .tapTodoCategory(let category): @@ -320,12 +312,17 @@ private extension HomeViewModel { case .deleteWebPage(let page): if let index = state.webPages.firstIndex(where: { $0.id == page.id }) { deletedWebPageURLString = page.url.absoluteString - state.webPages.remove(at: index) + state.webPages[index].isHidden = true setToast(&state, isPresented: true, for: .deleteWebPage) - return [.deleteWebPage(page, index)] + return [.deleteWebPage(page)] } case .undoDeleteWebPage: guard let deletedWebPageURLString else { return [] } + if let index = state.webPages.firstIndex(where: { + $0.url.absoluteString == deletedWebPageURLString + }) { + state.webPages[index].isHidden = false + } self.deletedWebPageURLString = nil return [.undoDeleteWebPage(deletedWebPageURLString)] default: @@ -339,6 +336,16 @@ private extension HomeViewModel { switch action { case .setLoading(let loadingTarget, let isLoading): setLoading(&state, loadingTarget: loadingTarget, isLoading: isLoading) + case .setWebPageHidden(let webPageURL, let isHidden): + if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { + state.webPages[index].isHidden = isHidden + } + case .handleWebPageDeleteFailure(let webPageURL): + if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { + state.webPages[index].isHidden = false + } else { + state.needsWebPageRefresh = true + } case .setTodoCategory(let preferences): state.preferences = preferences state.recentTodos = syncRecentTodos(state.recentTodos, preferences: preferences) @@ -346,16 +353,7 @@ private extension HomeViewModel { state.recentTodos = todos case .updateWebPages(let pages): state.webPages = pages - case .restoreWebPage(let page, let index): - if state.webPages.contains(where: { $0.id == page.id }) { break } - if index <= state.webPages.count { - state.webPages.insert(page, at: index) - } else { - state.webPages.append(page) - } - if deletedWebPageURLString == page.url.absoluteString { - deletedWebPageURLString = nil - } + state.needsWebPageRefresh = false default: break } diff --git a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift index 7738db5b..f369bab4 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift @@ -37,7 +37,7 @@ final class PushNotificationListViewModel: Store { case resetPagination case setHasMore(Bool) case syncNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?, hasMore: Bool) - case restoreNotification(PushNotificationItem, Int) + case setNotificationHidden(String, Bool) case toggleSortOption case setTimeFilter(PushNotificationQuery.TimeFilter) case toggleUnreadOnly @@ -48,7 +48,7 @@ final class PushNotificationListViewModel: Store { enum SideEffect { case fetchNotifications(PushNotificationQuery, cursor: PushNotificationCursor?) - case delete(PushNotificationItem, Int) + case delete(PushNotificationItem) case undoDelete(String) case toggleRead(String) } @@ -61,7 +61,7 @@ final class PushNotificationListViewModel: Store { private let fetchQueryUseCase: FetchPushNotificationQueryUseCase private let updateQueryUseCase: UpdatePushNotificationQueryUseCase private let loadingState = LoadingState() - private var undoDeleteNotificationId: String? + private var undoNotificationId: String? private var cancellable: AnyCancellable? init( @@ -104,7 +104,7 @@ final class PushNotificationListViewModel: Store { effects = reduceByView(action, state: &state) case .setLoading, .appendNotifications, .resetPagination, .setHasMore, - .syncNotifications, .restoreNotification: + .syncNotifications, .setNotificationHidden: effects = reduceByRun(action, state: &state) } @@ -145,31 +145,23 @@ final class PushNotificationListViewModel: Store { } } - case .delete(let item, let index): - beginLoading(.delayed) + case .delete(let item): Task { do { - defer { endLoading(.delayed) } try await deleteUseCase.execute(item.id) } catch { - send(.restoreNotification(item, index)) + send(.setNotificationHidden(item.id, false)) send(.setAlert(isPresented: true)) } } case .undoDelete(let notificationId): - beginLoading(.delayed) Task { - // endLoading(.delayed)를 defer로 두지 않는 이유 - // send(.fetchNotifications)가 같은 턴에서 beginLoading(.delayed)를 먼저 올린 뒤 - // delayed 로딩을 내려야 같은 isLoading이 끊기지 않기 때문 do { try await undoDeleteUseCase.execute(notificationId) } catch { + send(.setNotificationHidden(notificationId, true)) send(.setAlert(isPresented: true)) } - - send(.fetchNotifications) - endLoading(.delayed) } case .toggleRead(let todoId): beginLoading(.delayed) @@ -190,11 +182,11 @@ private extension PushNotificationListViewModel { func reduceByUser(_ action: Action, state: inout State) -> [SideEffect] { switch action { case .deleteNotification(let item): - if let index = state.notifications.firstIndex(where: { $0.id == item.id }) { - undoDeleteNotificationId = item.id - state.notifications.remove(at: index) + if state.notifications.contains(where: { $0.id == item.id }) { + self.undoNotificationId = item.id + setNotificationHidden(&state, notificationId: item.id, isHidden: true) setToast(&state, isPresented: true) - return [.delete(item, index)] + return [.delete(item)] } return [] case .toggleRead(let item): @@ -203,9 +195,10 @@ private extension PushNotificationListViewModel { return [.toggleRead(item.todoId)] } case .undoDelete: - guard let undoDeleteNotificationId else { return [] } - self.undoDeleteNotificationId = nil - return [.undoDelete(undoDeleteNotificationId)] + guard let undoNotificationId else { return [] } + setNotificationHidden(&state, notificationId: undoNotificationId, isHidden: false) + self.undoNotificationId = nil + return [.undoDelete(undoNotificationId)] case .setAlert(let isPresented): setAlert(&state, isPresented: isPresented) case .toggleSortOption: @@ -251,7 +244,8 @@ private extension PushNotificationListViewModel { case .setToast(let isPresented): setToast(&state, isPresented: isPresented) if !isPresented { - undoDeleteNotificationId = nil + state.notifications.removeAll { $0.isHidden } + self.undoNotificationId = nil } case .setSelectedTodoId(let todoId): state.selectedTodoId = todoId @@ -271,24 +265,20 @@ private extension PushNotificationListViewModel { state.notifications = [] state.nextCursor = nil case .appendNotifications(let notifications, let nextCursor): - state.notifications.append(contentsOf: notifications) + state.notifications.append(contentsOf: mergedHiddenNotifications( + currentNotifications: state.notifications, + incomingNotifications: notifications + )) state.nextCursor = nextCursor case .syncNotifications(let notifications, let nextCursor, let hasMore): - state.notifications = notifications + state.notifications = mergedHiddenNotifications( + currentNotifications: state.notifications, + incomingNotifications: notifications + ) state.nextCursor = nextCursor state.hasMore = hasMore - case .restoreNotification(let notification, let index): - if state.notifications.contains(where: { $0.id == notification.id }) { break } - - if index <= state.notifications.count { - state.notifications.insert(notification, at: index) - } else { - state.notifications.append(notification) - } - - if undoDeleteNotificationId == notification.id { - undoDeleteNotificationId = nil - } + case .setNotificationHidden(let notificationId, let isHidden): + setNotificationHidden(&state, notificationId: notificationId, isHidden: isHidden) default: break } @@ -314,6 +304,38 @@ private extension PushNotificationListViewModel { state.showToast = isPresented } + func setNotificationHidden( + _ state: inout State, + notificationId: String, + isHidden: Bool + ) { + if let notificationIndex = state.notifications.firstIndex(where: { + $0.id == notificationId + }) { + state.notifications[notificationIndex].isHidden = isHidden + } + } + + func mergedHiddenNotifications( + currentNotifications: [PushNotificationItem], + incomingNotifications: [PushNotificationItem] + ) -> [PushNotificationItem] { + let hiddenNotificationIds = Set(currentNotifications + .filter(\.isHidden) + .map(\.id) + ) + + return incomingNotifications.map { incomingNotification in + guard hiddenNotificationIds.contains(incomingNotification.id) else { + return incomingNotification + } + + var hiddenNotification = incomingNotification + hiddenNotification.isHidden = true + return hiddenNotification + } + } + func startObservingNotifications( query: PushNotificationQuery, limit: Int diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index c0138270..9700fc49 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -56,7 +56,7 @@ final class TodoListViewModel: Store { case fetchSearchResults([TodoListItem]) case didToggleCompleted(TodoListItem) case didTogglePinned(TodoListItem) - case restoreTodo(TodoListItem, Int) + case setTodoHidden(String, Bool) case setLoading(Bool) case appendTodos([TodoListItem], nextCursor: TodoCursor?) case resetPagination @@ -70,7 +70,7 @@ final class TodoListViewModel: Store { case loadNextPage case search(String) case upsert(Todo) - case delete(TodoListItem, Int) + case delete(TodoListItem) case undoDelete(String) case toggleCompleted(TodoListItem) case togglePinned(TodoListItem) @@ -88,7 +88,7 @@ final class TodoListViewModel: Store { private let deleteTodoUseCase: DeleteTodoUseCase private let undoDeleteTodoUseCase: UndoDeleteTodoUseCase private let loadingState = LoadingState() - private var undoDeleteTodoId: String? + private var undoTodoId: String? private var nextCursor: TodoCursor? private var searchTasks: [SearchTaskKind: Task] = [:] private let searchDebounceDelay: Double = 0.4 @@ -137,7 +137,7 @@ final class TodoListViewModel: Store { effects = reduceByView(action, state: &state) case .applySearchQuery, .fetchSearchResults, .didToggleCompleted, .didTogglePinned, - .restoreTodo, .setLoading, .appendTodos, .resetPagination, .setHasMore: + .setTodoHidden, .setLoading, .appendTodos, .resetPagination, .setHasMore: effects = reduceByRun(action, state: &state) } @@ -250,29 +250,23 @@ final class TodoListViewModel: Store { send(.setAlert(true)) } } - case .delete(let item, let index): + case .delete(let item): Task { do { try await deleteTodoUseCase.execute(item.id) } catch { - send(.restoreTodo(item, index)) + send(.setTodoHidden(item.id, false)) send(.setAlert(true)) } } case .undoDelete(let todoId): - beginLoading(.delayed) Task { - // endLoading(.delayed)를 defer로 두지 않는 이유 - // send(.refresh)가 같은 턴에서 beginLoading(.delayed)를 먼저 올린 뒤 - // delayed 로딩을 내려야 같은 isLoading이 끊기지 않기 때문 do { try await undoDeleteTodoUseCase.execute(todoId) } catch { + send(.setTodoHidden(todoId, true)) send(.setAlert(true)) } - - send(.refresh) - endLoading(.delayed) } } } @@ -290,11 +284,11 @@ private extension TodoListViewModel { case .setShowEditor(let value): state.showEditor = value case .swipeTodo(let todo): - if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { - undoDeleteTodoId = todo.id - state.todos.remove(at: index) + if state.todos.contains(where: { $0.id == todo.id }) { + self.undoTodoId = todo.id + setTodoHidden(&state, todoId: todo.id, isHidden: true) setToast(&state, isPresented: true) - return [.delete(todo, index)] + return [.delete(todo)] } case .setSortTarget(let target): state.query.sortTarget = target @@ -331,9 +325,10 @@ private extension TodoListViewModel { case .tapTogglePinned(let todo): return [.togglePinned(todo)] case .undoDelete: - guard let undoDeleteTodoId else { return [] } - self.undoDeleteTodoId = nil - return [.undoDelete(undoDeleteTodoId)] + guard let undoTodoId else { return [] } + setTodoHidden(&state, todoId: undoTodoId, isHidden: false) + self.undoTodoId = nil + return [.undoDelete(undoTodoId)] default: break } @@ -360,7 +355,11 @@ private extension TodoListViewModel { } case .setToast(let isPresented): setToast(&state, isPresented: isPresented) - if !isPresented { undoDeleteTodoId = nil } + if !isPresented { + state.todos.removeAll { $0.isHidden } + state.searchResults.removeAll { $0.isHidden } + self.undoTodoId = nil + } case .upsertTodo(let todo): return [.upsert(todo)] default: @@ -389,18 +388,8 @@ private extension TodoListViewModel { if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { state.todos[index] = todo } - case .restoreTodo(let todo, let index): - if state.todos.contains(where: { $0.id == todo.id }) { break } - - if index <= state.todos.count { - state.todos.insert(todo, at: index) - } else { - state.todos.append(todo) - } - - if undoDeleteTodoId == todo.id { - undoDeleteTodoId = nil - } + case .setTodoHidden(let todoId, let isHidden): + setTodoHidden(&state, todoId: todoId, isHidden: isHidden) case .setLoading(let value): state.isLoading = value case .appendTodos(let todos, let nextCursor): @@ -438,6 +427,20 @@ private extension TodoListViewModel { state.showToast = isPresented } + func setTodoHidden( + _ state: inout State, + todoId: String, + isHidden: Bool + ) { + if let todoIndex = state.todos.firstIndex(where: { $0.id == todoId }) { + state.todos[todoIndex].isHidden = isHidden + } + + if let searchResultIndex = state.searchResults.firstIndex(where: { $0.id == todoId }) { + state.searchResults[searchResultIndex].isHidden = isHidden + } + } + func scheduleDebouncedSearch(_ query: String) { searchTasks[.debounce]?.cancel() let debounceTask = Task { [weak self] in diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 62d9b01d..97a40473 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -459,6 +459,23 @@ } } }, + "home_web_refresh_required" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't update web pages. Tap to refresh." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "웹페이지를 불러오지 못했어요. 탭해서 새로고침해주세요." + } + } + } + }, "home_webpage_input_message" : { "extractionState" : "manual", "localizations" : { diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index f428413c..9a704266 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -227,10 +227,24 @@ struct HomeView: View { private var webPageSection: some View { Section { + let webPages = viewModel.state.webPages.filter { !$0.isHidden } if viewModel.state.isWebPageLoading { LoadingView() .id(UUID()) // id 부여를 통해 렌더링 강제 - } else if viewModel.state.webPages.isEmpty { + } else if viewModel.state.needsWebPageRefresh { + Button { + viewModel.send(.refreshWebPages) + } label: { + HStack { + Spacer() + Text(String(localized: "home_web_refresh_required")) + .font(.callout) + .multilineTextAlignment(.center) + Spacer() + } + } + .buttonStyle(.plain) + } else if webPages.isEmpty { HStack { Spacer() Text(String(localized: "home_web_empty")) @@ -238,7 +252,7 @@ struct HomeView: View { Spacer() } } else { - ForEach(viewModel.state.webPages, id: \.id) { page in + ForEach(webPages, id: \.id) { page in webResultRow(page) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) diff --git a/DevLog/UI/Home/TodoListView.swift b/DevLog/UI/Home/TodoListView.swift index 0b194299..2b2d59c8 100644 --- a/DevLog/UI/Home/TodoListView.swift +++ b/DevLog/UI/Home/TodoListView.swift @@ -122,10 +122,12 @@ struct TodoListView: View { } private var todoListContent: some View { - ZStack { + let visibleTodos = viewModel.state.todos.filter { !$0.isHidden } + + return ZStack { List { Group { - if viewModel.state.todos.isEmpty, !viewModel.state.isLoading { + if visibleTodos.isEmpty, !viewModel.state.isLoading { HStack { Spacer() Text(String(localized: "todo_list_empty")) @@ -134,8 +136,7 @@ struct TodoListView: View { } .listRowSeparator(.hidden) } else { - let todos = viewModel.state.todos - ForEach(Array(zip(todos.indices, todos)), id: \.1.id) { idx, todo in + ForEach(Array(zip(visibleTodos.indices, visibleTodos)), id: \.1.id) { idx, todo in Button { router.push(Path.detail(todo.id)) } label: { @@ -152,7 +153,7 @@ struct TodoListView: View { } } .onAppear { - let lastID = viewModel.state.todos.last?.id + let lastID = visibleTodos.last?.id if todo.id == lastID, viewModel.state.hasMore { viewModel.send(.loadNextPage) } @@ -207,7 +208,7 @@ struct TodoListView: View { .offset(y: headerOffset) } .refreshable { viewModel.send(.refresh) } - .scrollDisabled(viewModel.state.todos.isEmpty || viewModel.state.isLoading) + .scrollDisabled(visibleTodos.isEmpty || viewModel.state.isLoading) if viewModel.state.isLoading { LoadingView() @@ -239,7 +240,7 @@ struct TodoListView: View { @ViewBuilder private var searchResultsContent: some View { - let searchResults = viewModel.state.searchResults + let searchResults = viewModel.state.searchResults.filter { !$0.isHidden } let limit = viewModel.searchResultsLimit let displayedTodos = viewModel.state.showAllSearchResults ? searchResults diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index f266e18f..593b463e 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -83,9 +83,10 @@ struct PushNotificationListView: View { } private var notificationList: some View { - List { + let visibleNotifications = viewModel.state.notifications.filter { !$0.isHidden } + return List { Group { - if viewModel.state.notifications.isEmpty { + if visibleNotifications.isEmpty { HStack { Spacer() Text(String(localized: "push_notifications_empty")) @@ -94,8 +95,10 @@ struct PushNotificationListView: View { } .listRowSeparator(.hidden) } else { - let notifications = viewModel.state.notifications - ForEach(Array(zip(notifications.indices, notifications)), id: \.1.id) { idx, notification in + ForEach( + Array(zip(visibleNotifications.indices, visibleNotifications)), + id: \.1.id + ) { index, notification in Button { viewModel.send(.tapNotification(notification)) } label: { @@ -104,15 +107,15 @@ struct PushNotificationListView: View { } .buttonStyle(.plain) .onAppear { - let lastID = viewModel.state.notifications.last?.id - if notification.id == lastID, viewModel.state.hasMore { + let lastId = visibleNotifications.last?.id + if notification.id == lastId, viewModel.state.hasMore { viewModel.send(.loadNextPage) } } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) .overlay(alignment: .top) { if #available(iOS 26.0, *) { - if idx == 0 { + if index == 0 { Divider() .padding(.horizontal, -16) }