diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index b95476e74..38374f17f 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -1514,7 +1514,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIconAlternate0 AppIconAlternate6 AppIconAlternate7 AppIconAlternate8 AppIconAlternate10 AppIconAlternate11 AppIconAlternate12 AppIconAlternate13 AppIconAlternate14 AppIconAlternate15 AppIconAlternate16 AppIconAlternate17 AppIconAlternate19 AppIconAlternate18 AppIconAlternate20 AppIconAlternate21 AppIconAlternate22 AppIconAlternate23 AppIconAlternate24 AppIconAlternate25 AppIconAlternate26 AppIconAlternate27 AppIconAlternate28 AppIconAlternate29 AppIconAlternate30 AppIconAlternate31 AppIconAlternate32 AppIconAlternate33 AppIconAlternate34 AppIconAlternate35 AppIconAlternate36 AppIconAlternate37 AppIconAlternate38 AppIconAlternate39 AppIconAlternate40 AppIconAlternate42 AppIconAlternate2 AppIconAlternate41 AppIconAlternate45 AppIconAlternate44 AppIconAlternate1 AppIconAlternate4 AppIconAlternate3 AppIconAlternate5 AppIconAlternate46 AppIconAlternate9 AppIconAlternate43"; + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIconAlternate0 AppIconAlternate6 AppIconAlternate7 AppIconAlternate8 AppIconAlternate10 AppIconAlternate11 AppIconAlternate12 AppIconAlternate13 AppIconAlternate14 AppIconAlternate15 AppIconAlternate16 AppIconAlternate17 AppIconAlternate19 AppIconAlternate18 AppIconAlternate20 AppIconAlternate21 AppIconAlternate22 AppIconAlternate23 AppIconAlternate24 AppIconAlternate25 AppIconAlternate26 AppIconAlternate27 AppIconAlternate28 AppIconAlternate29 AppIconAlternate30 AppIconAlternate31 AppIconAlternate32 AppIconAlternate33 AppIconAlternate34 AppIconAlternate35 AppIconAlternate36 AppIconAlternate37 AppIconAlternate38 AppIconAlternate39 AppIconAlternate40 AppIconAlternate42 AppIconAlternate2 AppIconAlternate41 AppIconAlternate45 AppIconAlternate44 AppIconAlternate1 AppIconAlternate4 AppIconAlternate3 AppIconAlternate5 AppIconAlternate46 AppIconAlternate9 AppIconAlternate49 AppIconAlternate48 AppIconAlternate47 AppIconAlternate43"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; @@ -1581,7 +1581,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIconAlternate0 AppIconAlternate6 AppIconAlternate7 AppIconAlternate8 AppIconAlternate10 AppIconAlternate11 AppIconAlternate12 AppIconAlternate13 AppIconAlternate14 AppIconAlternate15 AppIconAlternate16 AppIconAlternate17 AppIconAlternate19 AppIconAlternate18 AppIconAlternate20 AppIconAlternate21 AppIconAlternate22 AppIconAlternate23 AppIconAlternate24 AppIconAlternate25 AppIconAlternate26 AppIconAlternate27 AppIconAlternate28 AppIconAlternate29 AppIconAlternate30 AppIconAlternate31 AppIconAlternate32 AppIconAlternate33 AppIconAlternate34 AppIconAlternate35 AppIconAlternate36 AppIconAlternate37 AppIconAlternate38 AppIconAlternate39 AppIconAlternate40 AppIconAlternate42 AppIconAlternate2 AppIconAlternate41 AppIconAlternate45 AppIconAlternate44 AppIconAlternate1 AppIconAlternate4 AppIconAlternate3 AppIconAlternate5 AppIconAlternate46 AppIconAlternate9 AppIconAlternate43"; + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIconAlternate0 AppIconAlternate6 AppIconAlternate7 AppIconAlternate8 AppIconAlternate10 AppIconAlternate11 AppIconAlternate12 AppIconAlternate13 AppIconAlternate14 AppIconAlternate15 AppIconAlternate16 AppIconAlternate17 AppIconAlternate19 AppIconAlternate18 AppIconAlternate20 AppIconAlternate21 AppIconAlternate22 AppIconAlternate23 AppIconAlternate24 AppIconAlternate25 AppIconAlternate26 AppIconAlternate27 AppIconAlternate28 AppIconAlternate29 AppIconAlternate30 AppIconAlternate31 AppIconAlternate32 AppIconAlternate33 AppIconAlternate34 AppIconAlternate35 AppIconAlternate36 AppIconAlternate37 AppIconAlternate38 AppIconAlternate39 AppIconAlternate40 AppIconAlternate42 AppIconAlternate2 AppIconAlternate41 AppIconAlternate45 AppIconAlternate44 AppIconAlternate1 AppIconAlternate4 AppIconAlternate3 AppIconAlternate5 AppIconAlternate46 AppIconAlternate9 AppIconAlternate49 AppIconAlternate48 AppIconAlternate47 AppIconAlternate43"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; diff --git a/IceCubesApp/App/Tabs/Settings/IconSelectorView.swift b/IceCubesApp/App/Tabs/Settings/IconSelectorView.swift index 44631bb37..b83073ff5 100644 --- a/IceCubesApp/App/Tabs/Settings/IconSelectorView.swift +++ b/IceCubesApp/App/Tabs/Settings/IconSelectorView.swift @@ -27,6 +27,8 @@ struct IconSelectorView: View { case alt39, alt40, alt41, alt42, alt43 case alt44, alt45 case alt46 + case alt47, alt48 + case alt49 var appIconName: String { return "AppIconAlternate\(rawValue)" @@ -51,6 +53,8 @@ struct IconSelectorView: View { IconSelector(title: "\("settings.app.icon.designed-by".localized) Duncan Horne", icons: [.alt38]), IconSelector(title: "\("settings.app.icon.designed-by".localized) BeAware@social.beaware.live", icons: [.alt39, .alt40, .alt41, .alt42, .alt43]), IconSelector(title: "\("settings.app.icon.designed-by".localized) Simone Margio", icons: [.alt44, .alt45]), + IconSelector(title: "\("settings.app.icon.designed-by".localized) Peter Broqvist (@PKB)", icons: [.alt47, .alt48]), + IconSelector(title: "\("settings.app.icon.designed-by".localized) Oz Tsori (@oztsori)", icons: [.alt49]), ] } diff --git a/IceCubesApp/Assets.xcassets/AppIconAlternate47.appiconset/AppIconAlternate47-fs8.png b/IceCubesApp/Assets.xcassets/AppIconAlternate47.appiconset/AppIconAlternate47-fs8.png new file mode 100644 index 000000000..becab913f Binary files /dev/null and b/IceCubesApp/Assets.xcassets/AppIconAlternate47.appiconset/AppIconAlternate47-fs8.png differ diff --git a/IceCubesApp/Assets.xcassets/AppIconAlternate47.appiconset/Contents.json b/IceCubesApp/Assets.xcassets/AppIconAlternate47.appiconset/Contents.json new file mode 100644 index 000000000..4b183ef88 --- /dev/null +++ b/IceCubesApp/Assets.xcassets/AppIconAlternate47.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIconAlternate47-fs8.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IceCubesApp/Assets.xcassets/AppIconAlternate48.appiconset/AppIconAlternate48-fs8.png b/IceCubesApp/Assets.xcassets/AppIconAlternate48.appiconset/AppIconAlternate48-fs8.png new file mode 100644 index 000000000..254022d4c Binary files /dev/null and b/IceCubesApp/Assets.xcassets/AppIconAlternate48.appiconset/AppIconAlternate48-fs8.png differ diff --git a/IceCubesApp/Assets.xcassets/AppIconAlternate48.appiconset/Contents.json b/IceCubesApp/Assets.xcassets/AppIconAlternate48.appiconset/Contents.json new file mode 100644 index 000000000..d50e3d414 --- /dev/null +++ b/IceCubesApp/Assets.xcassets/AppIconAlternate48.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIconAlternate48-fs8.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IceCubesApp/Assets.xcassets/AppIconAlternate49.appiconset/AppIconAlternate49-fs8.png b/IceCubesApp/Assets.xcassets/AppIconAlternate49.appiconset/AppIconAlternate49-fs8.png new file mode 100644 index 000000000..9e04956f4 Binary files /dev/null and b/IceCubesApp/Assets.xcassets/AppIconAlternate49.appiconset/AppIconAlternate49-fs8.png differ diff --git a/IceCubesApp/Assets.xcassets/AppIconAlternate49.appiconset/Contents.json b/IceCubesApp/Assets.xcassets/AppIconAlternate49.appiconset/Contents.json new file mode 100644 index 000000000..8e3f36177 --- /dev/null +++ b/IceCubesApp/Assets.xcassets/AppIconAlternate49.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIconAlternate49-fs8.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift b/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift index d0136577e..6dccb7f82 100644 --- a/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift +++ b/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift @@ -307,109 +307,100 @@ extension TimelineViewModel: StatusesFetcher { private func fetchNewPagesFrom(latestStatus: String, client: Client) async throws { canStreamEvents = false let initialTimeline = timeline - var newStatuses: [Status] = await fetchNewPages(minId: latestStatus, maxPages: 5) - - // Dedup statuses, a status with the same id could have been streamed in. - let ids = await datasource.get().map(\.id) - newStatuses = newStatuses.filter { status in - !ids.contains(where: { $0 == status.id }) - } - - StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client) - - // If no new statuses, resume streaming and exit. - guard !newStatuses.isEmpty else { - canStreamEvents = true - return - } - - // If the timeline is not visible, we don't update it as it would mess up the user position. - guard isTimelineVisible else { + + let newStatuses = await fetchAndDedupNewStatuses(latestStatus: latestStatus, client: client) + + guard !newStatuses.isEmpty, + isTimelineVisible, + !Task.isCancelled, + initialTimeline == timeline else { canStreamEvents = true return } - - // Return if task has been cancelled. - guard !Task.isCancelled else { - canStreamEvents = true - return + + await updateTimelineWithNewStatuses(newStatuses) + + if !Task.isCancelled, let latest = await datasource.get().first { + pendingStatusesObserver.isLoadingNewStatuses = true + try await fetchNewPagesFrom(latestStatus: latest.id, client: client) } - - // As this is a long runnign task we need to ensure that the user didn't changed the timeline filter. - guard initialTimeline == timeline else { - canStreamEvents = true - return + } + + private func fetchAndDedupNewStatuses(latestStatus: String, client: Client) async -> [Status] { + var newStatuses = await fetchNewPages(minId: latestStatus, maxPages: 5) + let ids = await datasource.get().map(\.id) + newStatuses = newStatuses.filter { status in + !ids.contains(where: { $0 == status.id }) } - - // Keep track of the top most status, so we can scroll back to it after view update. + StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client) + return newStatuses + } + + private func updateTimelineWithNewStatuses(_ newStatuses: [Status]) async { let topStatus = await datasource.getFiltered().first - - // Insert new statuses in internal datasource. await datasource.insert(contentOf: newStatuses, at: 0) - - // Cache statuses for timeline. await cache() - - // Append new statuses in the timeline indicator. pendingStatusesObserver.pendingStatuses.insert(contentsOf: newStatuses.map(\.id), at: 0) - - // High chance the user is scrolled to the top. - // We need to update the statuses state, and then scroll to the previous top most status. - if let topStatus, visibileStatuses.contains(where: { $0.id == topStatus.id }), scrollToTopVisible { - pendingStatusesObserver.disableUpdate = true - let statuses = await datasource.getFiltered() - statusesState = .display(statuses: statuses, - nextPageState: statuses.count < 20 ? .none : .hasNextPage) - scrollToIndexAnimated = false - scrollToIndex = newStatuses.count + 1 - DispatchQueue.main.async { - self.pendingStatusesObserver.disableUpdate = false - self.canStreamEvents = true - } + + let statuses = await datasource.getFiltered() + let nextPageState: StatusesState.PagingState = statuses.count < 20 ? .none : .hasNextPage + + if let topStatus = topStatus, + visibileStatuses.contains(where: { $0.id == topStatus.id }), + scrollToTopVisible { + updateTimelineWithScrollToTop(newStatuses: newStatuses, statuses: statuses, nextPageState: nextPageState) } else { - // This will keep the scroll position (if the list is scrolled) and prepend statuses on the top. - let statuses = await datasource.getFiltered() - withAnimation { - statusesState = .display(statuses: statuses, - nextPageState: statuses.count < 20 ? .none : .hasNextPage) - canStreamEvents = true - } + updateTimelineWithAnimation(statuses: statuses, nextPageState: nextPageState) } - - if !Task.isCancelled, - let latest = await datasource.get().first - { - pendingStatusesObserver.isLoadingNewStatuses = true - try await fetchNewPagesFrom(latestStatus: latest.id, client: client) + } + + // Refresh the timeline while keeping the scroll position to the top status. + private func updateTimelineWithScrollToTop(newStatuses: [Status], statuses: [Status], nextPageState: StatusesState.PagingState) { + pendingStatusesObserver.disableUpdate = true + statusesState = .display(statuses: statuses, nextPageState: nextPageState) + scrollToIndexAnimated = false + scrollToIndex = newStatuses.count + 1 + + DispatchQueue.main.async { [weak self] in + self?.pendingStatusesObserver.disableUpdate = false + self?.canStreamEvents = true + } + } + + // Refresh the timeline while keeping the user current position. + // It works because a side effect of withAnimation is that it keep scroll position IF the List is not scrolled to the top. + private func updateTimelineWithAnimation(statuses: [Status], nextPageState: StatusesState.PagingState) { + withAnimation { + statusesState = .display(statuses: statuses, nextPageState: nextPageState) + canStreamEvents = true } } private func fetchNewPages(minId: String, maxPages: Int) async -> [Status] { guard let client else { return [] } - var pagesLoaded = 0 var allStatuses: [Status] = [] var latestMinId = minId do { - while - !Task.isCancelled, - let newStatuses: [Status] = - try await client.get(endpoint: timeline.endpoint(sinceId: nil, - maxId: nil, - minId: latestMinId, - offset: datasource.get().count)), - !newStatuses.isEmpty, - pagesLoaded < maxPages - { - pagesLoaded += 1 - + for _ in 1...maxPages { + if Task.isCancelled { break } + + let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint( + sinceId: nil, + maxId: nil, + minId: latestMinId, + offset: nil + )) + + if newStatuses.isEmpty { break } + StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client) - allStatuses.insert(contentsOf: newStatuses, at: 0) - latestMinId = newStatuses.first?.id ?? "" + latestMinId = newStatuses.first?.id ?? latestMinId } } catch { return allStatuses } + return allStatuses }