iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #10 from grainsocial/fix/story-pane-blur-flash

fix: skip blur flash on cached stories; lower viewed threshold to 1%

authored by

Hima and committed by
GitHub
4a4fceb7 a7716b47

+132 -8
+11
DEVELOPMENT.md
··· 36 36 37 37 Then re-run `just generate` before building. 38 38 39 + Pass your device UDID directly or via an env var: 40 + 41 + ```bash 42 + just device 00000000-0000000000000000 # explicit UDID 43 + just device $iphonemax # via shell env var 44 + ``` 45 + 46 + Find your device UDID with `xcrun devicectl list devices`. 47 + 39 48 ## Commands 40 49 41 50 ```bash ··· 48 57 just lint-fix # Fix lint violations 49 58 just release # Bump build, archive, upload to App Store Connect 50 59 ``` 60 + 61 + > **Note:** The Xcode project is generated from `project.yml` — run `just generate` after adding or removing Swift files, or after pulling changes that touch `project.yml`.
+11
Grain/Utilities/StoryImageCache.swift
··· 1 + import Foundation 2 + import Nuke 3 + 4 + /// Returns `true` if the fullsize image for `story` is already in `pipeline`'s memory cache. 5 + /// 6 + /// Extracted from `StoryViewer` so it can be tested without spinning up a SwiftUI view 7 + /// and without touching `ImagePipeline.shared`. 8 + func storyFullsizeCached(_ story: GrainStory?, in pipeline: ImagePipeline = .shared) -> Bool { 9 + guard let url = story.flatMap({ URL(string: $0.fullsize) }) else { return false } 10 + return pipeline.cache.cachedImage(for: ImageRequest(url: url)) != nil 11 + }
+17 -8
Grain/Views/Stories/StoryViewer.swift
··· 24 24 } catch { return } 25 25 guard !Task.isCancelled else { return } 26 26 progress = CGFloat(tick) / CGFloat(totalTicks) 27 - if !quarterFired, progress >= 0.25 { 27 + if !quarterFired, progress >= 0.01 { 28 28 quarterFired = true 29 29 onQuarter?() 30 30 } ··· 553 553 if action == .none || action == .badge { timer.start() } 554 554 } 555 555 556 + private func isFullsizeCached(_ story: GrainStory?) -> Bool { 557 + storyFullsizeCached(story) 558 + } 559 + 556 560 private func goToNext() { 557 561 guard canNavigate() else { return } 558 562 markCurrentStoryViewed() ··· 578 582 579 583 private func advanceStory(by delta: Int) { 580 584 timer.progress = 0 581 - currentStoryIndex += delta 582 - imageLoaded = false 585 + let newIndex = currentStoryIndex + delta 586 + let nextStory = stories.indices.contains(newIndex) ? stories[newIndex] : nil 587 + currentStoryIndex = newIndex 588 + if !isFullsizeCached(nextStory) { imageLoaded = false } 583 589 labelRevealed = false 584 590 showLocationCopied = false 585 591 prefetchStoryImages() ··· 782 788 } 783 789 784 790 private func presentStories(_ fetched: [GrainStory], resumeIndex: Int? = nil) { 785 - imageLoaded = false 786 - showLocationCopied = false 787 - stories = fetched 791 + let targetIndex: Int 788 792 if let resume = resumeIndex { 789 - currentStoryIndex = min(resume, max(fetched.count - 1, 0)) 793 + targetIndex = min(resume, max(fetched.count - 1, 0)) 790 794 } else { 791 795 let isOwn = fetched.first?.creator.did == auth.userDID 792 - currentStoryIndex = (unreadOnly && isOwn) ? 0 : viewedStories.firstUnviewedIndex(in: fetched) 796 + targetIndex = (unreadOnly && isOwn) ? 0 : viewedStories.firstUnviewedIndex(in: fetched) 793 797 } 798 + let targetStory = fetched.indices.contains(targetIndex) ? fetched[targetIndex] : nil 799 + if !isFullsizeCached(targetStory) { imageLoaded = false } 800 + showLocationCopied = false 801 + stories = fetched 802 + currentStoryIndex = targetIndex 794 803 labelRevealed = false 795 804 isLoadingStories = false 796 805 startTimerIfSafe()
+88
GrainTests/StoryImageCacheTests.swift
··· 1 + @testable import Grain 2 + import Nuke 3 + import XCTest 4 + 5 + final class StoryImageCacheTests: XCTestCase { 6 + // MARK: - Helpers 7 + 8 + private func makeStory(fullsize: String) -> GrainStory { 9 + GrainStory( 10 + uri: "at://did:plc:test/social.grain.story/\(UUID().uuidString)", 11 + cid: "bafy-test", 12 + creator: GrainProfile(cid: "cid", did: "did:plc:test", handle: "test.bsky.social"), 13 + thumb: "https://cdn.example.com/thumb.jpg", 14 + fullsize: fullsize, 15 + aspectRatio: AspectRatio(width: 4, height: 3), 16 + createdAt: "2024-01-01T00:00:00Z" 17 + ) 18 + } 19 + 20 + /// Builds an `ImagePipeline` backed entirely by an in-memory cache so tests 21 + /// never touch the network or the disk, and never pollute `ImagePipeline.shared`. 22 + private func makeIsolatedPipeline() -> ImagePipeline { 23 + var config = ImagePipeline.Configuration() 24 + config.dataLoader = DataLoader(configuration: { 25 + let cfg = URLSessionConfiguration.ephemeral 26 + cfg.protocolClasses = [] 27 + return cfg 28 + }()) 29 + config.imageCache = ImageCache() 30 + config.dataCache = nil 31 + return ImagePipeline(configuration: config) 32 + } 33 + 34 + private func seedCache(pipeline: ImagePipeline, url: URL) { 35 + let image = UIImage(systemName: "photo") ?? UIImage() 36 + let container = ImageContainer(image: image) 37 + pipeline.cache.storeCachedImage(container, for: ImageRequest(url: url)) 38 + } 39 + 40 + // MARK: - nil story 41 + 42 + func testNilStory_returnsFalse() { 43 + let pipeline = makeIsolatedPipeline() 44 + XCTAssertFalse(storyFullsizeCached(nil, in: pipeline)) 45 + } 46 + 47 + // MARK: - invalid / empty fullsize URL 48 + 49 + func testEmptyFullsizeURL_returnsFalse() { 50 + let pipeline = makeIsolatedPipeline() 51 + let story = makeStory(fullsize: "") 52 + XCTAssertFalse(storyFullsizeCached(story, in: pipeline)) 53 + } 54 + 55 + func testInvalidFullsizeURL_returnsFalse() { 56 + let pipeline = makeIsolatedPipeline() 57 + let story = makeStory(fullsize: "not a url !!!") 58 + XCTAssertFalse(storyFullsizeCached(story, in: pipeline)) 59 + } 60 + 61 + // MARK: - valid URL, not cached 62 + 63 + func testValidURL_notInCache_returnsFalse() { 64 + let pipeline = makeIsolatedPipeline() 65 + let story = makeStory(fullsize: "https://cdn.example.com/stories/abc.jpg") 66 + XCTAssertFalse(storyFullsizeCached(story, in: pipeline)) 67 + } 68 + 69 + // MARK: - valid URL, cached 70 + 71 + func testValidURL_inCache_returnsTrue() throws { 72 + let pipeline = makeIsolatedPipeline() 73 + let urlString = "https://cdn.example.com/stories/xyz.jpg" 74 + let story = makeStory(fullsize: urlString) 75 + try seedCache(pipeline: pipeline, url: XCTUnwrap(URL(string: urlString))) 76 + XCTAssertTrue(storyFullsizeCached(story, in: pipeline)) 77 + } 78 + 79 + func testValidURL_cachedInDifferentPipeline_doesNotAffectIsolatedPipeline() throws { 80 + let pipeline1 = makeIsolatedPipeline() 81 + let pipeline2 = makeIsolatedPipeline() 82 + let urlString = "https://cdn.example.com/stories/shared.jpg" 83 + let story = makeStory(fullsize: urlString) 84 + try seedCache(pipeline: pipeline1, url: XCTUnwrap(URL(string: urlString))) 85 + // pipeline2 should not see pipeline1's cache 86 + XCTAssertFalse(storyFullsizeCached(story, in: pipeline2)) 87 + } 88 + }
+5
project.yml
··· 43 43 base: 44 44 INFOPLIST_FILE: Grain/Info.plist 45 45 PRODUCT_BUNDLE_IDENTIFIER: ${BUNDLE_ID} 46 + PRODUCT_MODULE_NAME: Grain 46 47 MARKETING_VERSION: "1.0.0" 47 48 CURRENT_PROJECT_VERSION: "42" 48 49 CODE_SIGN_STYLE: Automatic ··· 82 83 settings: 83 84 base: 84 85 GENERATE_INFOPLIST_FILE: YES 86 + PRODUCT_NAME: GrainTests 87 + PRODUCT_MODULE_NAME: GrainTests 88 + TEST_HOST: $(BUILT_PRODUCTS_DIR)/$(BUNDLE_NAME).app/$(BUNDLE_NAME) 89 + BUNDLE_LOADER: $(TEST_HOST) 85 90 dependencies: 86 91 - target: Grain 87 92