feat: show only user related content #11

This commit is contained in:
Stefan Hausotte 2026-03-10 21:39:52 +01:00
parent 57fd862149
commit 781826004b
2 changed files with 118 additions and 9 deletions

View file

@ -9,7 +9,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
@State private var searchText = ""
@State private var searchTask: Task<Void, Never>?
@State private var showCreateFlow = false
@State private var involvementScope: InvolvementScope = .created
@State private var involvementScope: InvolvementScope = .involved
private let issueService: IssueService?
private let issueType: String
@ -70,7 +70,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
}
private var hasNonDefaultFilters: Bool {
stateFilter != .open || involvementScope != .created
stateFilter != .open || involvementScope != .involved
}
private var filterSummaryText: String {
@ -88,7 +88,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
private func scopeDisplayLabel(_ scope: InvolvementScope) -> String {
switch scope {
case .involved: "All"
case .involved: "All involvement"
case .created: "Created by you"
case .assigned: "Assigned to you"
case .mentioned: "Mentioned"
@ -242,12 +242,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
private func searchIssues(service: IssueService, page: Int, limit: Int) async throws -> [Issue] {
switch involvementScope {
case .involved:
// No involvement flags the API uses AND logic when multiple are set,
// so we omit all flags to show everything visible to the user.
try await service.searchIssues(
type: issueType, state: stateFilter.rawValue, query: searchText,
page: page, limit: limit,
)
try await searchInvolved(service: service, page: page, limit: limit)
case .created:
try await service.searchIssues(
type: issueType, state: stateFilter.rawValue, query: searchText,
@ -271,6 +266,60 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
}
}
/// Fires parallel requests for each involvement type and merges results.
/// The API uses AND logic when multiple flags are set, so we need separate
/// calls to get OR-union semantics for "All" involvement.
/// Individual requests that time out return empty so others still appear.
private func searchInvolved(service: IssueService, page: Int, limit: Int) async throws -> [Issue] {
let type = issueType
let state = stateFilter.rawValue
let query = searchText
let batches = try await withThrowingTaskGroup(of: [Issue].self) { group in
for flag in involvedFlags {
group.addTask {
try await resilientFetch {
try await service.searchIssues(
type: type, state: state, query: query, page: page, limit: limit,
assigned: flag == .assigned, created: flag == .created,
mentioned: flag == .mentioned, reviewRequested: flag == .reviewRequested,
)
}
}
}
return try await group.reduce(into: [[Issue]]()) { $0.append($1) }
}
return Self.deduplicateAndSort(batches)
}
private var involvedFlags: [InvolvementScope] {
var flags: [InvolvementScope] = [.created, .assigned, .mentioned]
if showReviewRequested { flags.append(.reviewRequested) }
return flags
}
/// Runs a fetch; returns `[]` on timeout so one slow request does not
/// block the rest. All other errors (auth, decoding, etc.) propagate.
private func resilientFetch(_ fetch: () async throws -> [Issue]) async throws -> [Issue] {
do {
return try await fetch()
} catch let urlError as URLError where urlError.code == .timedOut {
return []
}
}
/// Merges multiple batches of issues, removing duplicates by ID and
/// sorting by most recently updated first.
static func deduplicateAndSort(_ batches: [[Issue]]) -> [Issue] {
var seen = Set<Int>()
var merged = [Issue]()
for issue in batches.flatMap({ $0 }) where seen.insert(issue.id).inserted {
merged.append(issue)
}
return merged.sorted { $0.updatedAt > $1.updatedAt }
}
@discardableResult
private func reloadItems(clearItems: Bool = false) -> Task<Void, Never> {
guard let issueService else { return Task {} }

View file

@ -0,0 +1,60 @@
import ForgejoKit
import Foundation
import SwiftUI
import Testing
@testable import Forji
private func makeIssue(id: Int, updatedAt: Date = Date()) -> ForgejoKit.Issue {
ForgejoKit.Issue(
id: id, number: id,
title: "Issue \(id)", body: nil, state: "open",
user: User(id: 1, login: "u", fullName: "U", email: "u@e", avatarUrl: nil, isAdmin: false, created: Date()),
createdAt: Date(), updatedAt: updatedAt, comments: 0,
)
}
private typealias SUT = SearchableOverviewView<EmptyView, EmptyView, EmptyView>
@Suite("deduplicateAndSort")
struct DeduplicateAndSortTests {
@Test func emptyBatchesReturnEmpty() {
let result = SUT.deduplicateAndSort([[], []])
#expect(result.isEmpty)
}
@Test func singleBatchPreserved() {
let now = Date()
let issues = [
makeIssue(id: 1, updatedAt: now),
makeIssue(id: 2, updatedAt: now.addingTimeInterval(-10)),
]
let result = SUT.deduplicateAndSort([issues])
#expect(result.map { $0.id } == [1, 2])
}
@Test func duplicatesRemovedKeepsFirst() {
let a = makeIssue(id: 1, updatedAt: Date())
let b = makeIssue(id: 1, updatedAt: Date().addingTimeInterval(-100))
let result = SUT.deduplicateAndSort([[a], [b]])
#expect(result.count == 1)
#expect(result[0].id == 1)
}
@Test func sortedByUpdatedAtDescending() {
let old = makeIssue(id: 1, updatedAt: Date().addingTimeInterval(-200))
let mid = makeIssue(id: 2, updatedAt: Date().addingTimeInterval(-100))
let recent = makeIssue(id: 3, updatedAt: Date())
let result = SUT.deduplicateAndSort([[old], [recent], [mid]])
#expect(result.map { $0.id } == [3, 2, 1])
}
@Test func duplicatesAcrossMultipleBatches() {
let now = Date()
let a = makeIssue(id: 1, updatedAt: now)
let b = makeIssue(id: 2, updatedAt: now.addingTimeInterval(-10))
let c = makeIssue(id: 3, updatedAt: now.addingTimeInterval(-20))
let result = SUT.deduplicateAndSort([[a, b], [b, c], [a, c]])
#expect(result.map { $0.id } == [1, 2, 3])
}
}