Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feed improvements #54

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions Projects/App/AppFeature/Sources/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,14 @@ struct App: SwiftUI.App {
case .feed(.fetchStatuses): false
case .feed(.fetchStatusesResult(.failure(_))): true
case .feed(.fetchStatusesResult(.success(_))): false
case .feed(.statusTextRenderingFailed(_, _)): true
case .feed(.status(.element(_, .quickLookItem(.dismiss)))): true
case .feed(.status(.element(_, .quickLookItem(.presented(_))))): false
case .feed(.status(.element(_, .renderText))): false
case .feed(.status(.element(_, .textRendered(.failure(_))))): true
case .feed(.status(.element(_, .textRendered(.success(_))))): false
case .feed(.status(.element(_, .view(.attachmentTapped(_))))): true
case .feed(.status(.element(_, .view(.headerTapped)))): true
case .feed(.status(.element(_, .view(.linkTapped(_))))): true
case .feed(.status(.element(_, .view(.previewCardTapped)))): true
case .feed(.status(.element(_, .view(.quickLookItemChanged(_))))): true
case .feed(.status(.element(_, .view(.textSourceChanged)))): false
case .feed(.view(.refreshButtonTapped)): true
case .feed(.view(.refreshTask)): true
case .feed(.view(.seeMoreButtonTapped)): true
Expand Down
41 changes: 29 additions & 12 deletions Projects/App/FeedFeature/Sources/FeedReducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ public struct FeedReducer: Reducer, Sendable {

public enum Action: Sendable, ViewAction {
case fetchStatuses
case fetchStatusesResult(Result<[Mastodon.Status], any Error>)
case fetchStatusesResult(Result<IdentifiedArrayOf<StatusReducer.State>, any Error>)
case statusTextRenderingFailed(Status.ID, any Error)
case status(IdentifiedActionOf<StatusReducer>)
case view(View)

Expand All @@ -38,6 +39,7 @@ public struct FeedReducer: Reducer, Sendable {
@Dependency(\.continuousClock) var clock
@Dependency(\.mastodon) var mastodon
@Dependency(\.openURL) var openURL
@Dependency(\.statusTextRenderer) var render
static let mastodonAccountId = "108131495937150285"
static let mastodonAccountURL = URL(string: "https://mastodon.social/@darrarski")!

Expand All @@ -48,36 +50,51 @@ public struct FeedReducer: Reducer, Sendable {
switch action {
case .fetchStatuses:
state.isLoading = true
let oldStates = state.statuses
return .run { send in
try await clock.sleep(for: .seconds(0.5))
let result = await Result {
try await mastodon.getAccountStatuses(
let statuses: [Mastodon.Status]
do {
statuses = try await mastodon.getAccountStatuses(
accountId: Self.mastodonAccountId,
limit: 40,
excludeReplies: true
)
} catch {
await send(.fetchStatusesResult(.failure(error)))
return
}
await send(.fetchStatusesResult(result))
var newStates: IdentifiedArrayOf<StatusReducer.State> = []
for status in statuses {
let oldState = oldStates[id: status.id]
var newState = oldState ?? StatusReducer.State(status: status)
newState.status = status
if newState.text == nil || newState.textSource != oldState?.textSource {
do {
newState.text = try render(newState.textSource)
} catch {
await send(.statusTextRenderingFailed(status.id, error))
}
}
newStates.append(newState)
}
await send(.fetchStatusesResult(.success(newStates)))
}
.cancellable(id: CancelId.fetchStatuses, cancelInFlight: true)

case .fetchStatusesResult(let result):
state.isLoading = false
switch result {
case .success(let statuses):
state.statuses = IdentifiedArray(
uniqueElements: statuses.map { status in
var state = state.statuses[id: status.id] ?? StatusReducer.State(status: status)
state.status = status
return state
}
)

state.statuses = statuses
case .failure(_):
break
}
return .none

case .statusTextRenderingFailed(_, _):
return .none

case .status(_):
return .none

Expand Down
22 changes: 0 additions & 22 deletions Projects/App/FeedFeature/Sources/StatusReducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ public struct StatusReducer: Reducer, Sendable {

public enum Action: Sendable, ViewAction {
case quickLookItem(PresentationAction<Never>)
case renderText
case textRendered(Result<AttributedString, any Error>)
case view(View)

@CasePathable
Expand All @@ -54,36 +52,19 @@ public struct StatusReducer: Reducer, Sendable {
case linkTapped(URL)
case previewCardTapped
case quickLookItemChanged(URL?)
case textSourceChanged
}
}

public init() {}

@Dependency(\.openURL) var openURL
@Dependency(\.statusTextRenderer) var render

public var body: some ReducerOf<Self> {
Reduce<State, Action> { state, action in
switch action {
case .quickLookItem(_):
return .none

case .renderText:
let html = state.textSource
return .run { send in
let result = Result { try render(html) }
await send(.textRendered(result))
}

case .textRendered(.failure(_)):
state.text = AttributedString(state.textSource)
return .none

case .textRendered(.success(let text)):
state.text = text
return .none

case .view(.attachmentTapped(let id)):
guard let attachment = state.attachments[id: id],
let url = URL(string: attachment.url)
Expand Down Expand Up @@ -121,9 +102,6 @@ public struct StatusReducer: Reducer, Sendable {
case .view(.quickLookItemChanged(let url)):
state.quickLookItem = url
return .none

case .view(.textSourceChanged):
return .send(.renderText)
}
}
.ifLet(\.$quickLookItem, action: \.quickLookItem) {
Expand Down
4 changes: 0 additions & 4 deletions Projects/App/FeedFeature/Sources/StatusView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,6 @@ public struct StatusView: View {
Text(text)
.redacted(reason: isPlaceholder ? .placeholder : [])
.disabled(isPlaceholder)
.onChange(of: store.textSource, initial: true) { old, new in
guard old != new || store.text == nil else { return }
send(.textSourceChanged)
}
.environment(\.openURL, OpenURLAction { url in
send(.linkTapped(url))
return .discarded
Expand Down
99 changes: 68 additions & 31 deletions Projects/App/FeedFeature/Tests/FeedReducerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ final class FeedReducerTests: XCTestCase {
@MainActor func testFetchStatuses() async {
let clock = TestClock()
let didFetch = LockIsolated<[Mastodon.GetAccountStatuses.Request]>([])
let didRenderStatusText = LockIsolated(0)
let statuses = [Status].preview
let store = TestStore(initialState: FeedReducer.State()) {
FeedReducer()
Expand All @@ -16,6 +17,10 @@ final class FeedReducerTests: XCTestCase {
didFetch.withValue { $0.append(request) }
return statuses
}
$0.statusTextRenderer.render = {
didRenderStatusText.withValue { $0 += 1 }
return AttributedString($0)
}
}

await store.send(.fetchStatuses) {
Expand All @@ -30,9 +35,24 @@ final class FeedReducerTests: XCTestCase {
await store.receive(\.fetchStatusesResult.success) {
$0.isLoading = false
$0.statuses = .init(
uniqueElements: statuses.map { StatusReducer.State(status: $0) }
uniqueElements: statuses.map {
StatusReducer.State(
status: $0,
text: AttributedString(($0.reblog?.value ?? $0).content)
)
}
)
}
XCTAssertEqual(didRenderStatusText.value, statuses.count)
didRenderStatusText.setValue(0)
await store.send(.fetchStatuses) {
$0.isLoading = true
}
await clock.advance(by: .seconds(0.5))
await store.receive(\.fetchStatusesResult.success) {
$0.isLoading = false
}
XCTAssertEqual(didRenderStatusText.value, 0)
}

@MainActor func testFetchStatusesFailure() async {
Expand All @@ -52,6 +72,53 @@ final class FeedReducerTests: XCTestCase {
}
}

@MainActor func testFetchStatusesRenderingFailure() async {
let clock = TestClock()
let statuses = Array([Status].preview[0...2])
struct Failure: Error, Equatable {
var text: String
}
let store = TestStore(initialState: FeedReducer.State()) {
FeedReducer()
} withDependencies: {
$0.continuousClock = clock
$0.mastodon.getAccountStatuses.send = { _ in statuses }
$0.statusTextRenderer.render = { text in
if text == (statuses[1].reblog?.value ?? statuses[1]).content {
throw Failure(text: text)
}
return AttributedString(text)
}
}

await store.send(.fetchStatuses) {
$0.isLoading = true
}
await clock.advance(by: .seconds(0.5))
await store.receive { action in
guard case .statusTextRenderingFailed(let id, let error) = action,
let error = error as? Failure else {
return false
}
return (id, error) == (
statuses[1].id,
Failure(text: (statuses[1].reblog?.value ?? statuses[1]).content)
)
}
await store.receive(\.fetchStatusesResult.success) {
$0.isLoading = false
$0.statuses = .init(
uniqueElements: statuses.map {
var state = StatusReducer.State(status: $0)
if state.status.id != statuses[1].id {
state.text = AttributedString(($0.reblog?.value ?? $0).content)
}
return state
}
)
}
}

@MainActor func testViewTask() async {
let store = TestStore(initialState: FeedReducer.State()) {
FeedReducer()
Expand Down Expand Up @@ -107,34 +174,4 @@ final class FeedReducerTests: XCTestCase {
FeedReducer.mastodonAccountURL
])
}

@MainActor func testReloadStatusesPreservesTheirStates() async {
let statuses = Array([Status].preview[0...2])
let store = TestStore(initialState: FeedReducer.State(
statuses: [
StatusReducer.State(
status: statuses[0],
text: "Status 0 text"
),
StatusReducer.State(
status: statuses[1],
text: "Status 1 text"
),
StatusReducer.State(
status: statuses[2],
text: "Status 2 text"
),
],
isLoading: true
)) {
FeedReducer()
} withDependencies: {
$0.continuousClock = ImmediateClock()
$0.mastodon.getAccountStatuses.send = { _ in [] }
}

await store.send(.fetchStatusesResult(.success(statuses))) {
$0.isLoading = false
}
}
}
42 changes: 0 additions & 42 deletions Projects/App/FeedFeature/Tests/StatusReducerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,46 +207,4 @@ final class StatusReducerTests: XCTestCase {
allAttachments[3],
])
}

@MainActor func testTextRendering() async {
var status = [Status].preview.first!
status.content = "html content"
status.reblog = nil
let renderedText = AttributedString("redered text")
let didRender = LockIsolated<[String]>([])
let store = TestStore(initialState: StatusReducer.State(status: status)) {
StatusReducer()
} withDependencies: {
$0.statusTextRenderer.render = { text in
didRender.withValue { $0.append(text) }
return renderedText
}
}

await store.send(.view(.textSourceChanged))
await store.receive(\.renderText)
expectNoDifference(didRender.value, [status.content])
await store.receive(\.textRendered.success) {
$0.text = renderedText
}
}

@MainActor func testTextRenderingFailure() async {
struct Failure: Error, Equatable {}
let failure = Failure()
var status = [Status].preview.first!
status.content = "html content"
status.reblog = nil
let store = TestStore(initialState: StatusReducer.State(status: status)) {
StatusReducer()
} withDependencies: {
$0.statusTextRenderer.render = { _ in throw failure }
}

await store.send(.view(.textSourceChanged))
await store.receive(\.renderText)
await store.receive(\.textRendered.failure) {
$0.text = AttributedString(status.content)
}
}
}