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.

feat: story rings on all avatars with tap-to-view and long-press-to-profile

- Show gradient ring on avatars when user has active stories
- Tap avatar opens story viewer, long-press navigates to profile
- Scale ring thickness by avatar size (1.5/2.5/3.5pt tiers)
- Long-press story strip avatars to navigate to profile
- Tapping profile within story viewer navigates to their profile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+280 -71
+126 -60
Grain/Views/Components/GalleryCardView.swift
··· 3 3 4 4 private let logger = Logger(subsystem: "social.grain.grain", category: "GalleryCard") 5 5 6 - // MARK: - Self-animating heart that removes itself when done 6 + // MARK: - Self-animating heart with heap-backed state 7 7 8 - private struct DoubleTapHeart: Identifiable { 8 + @Observable 9 + @MainActor 10 + private final class HeartAnimationState: Identifiable { 9 11 let id = UUID() 10 12 let position: CGPoint 11 13 let rotation: Double 12 - } 13 - 14 - private struct DoubleTapHeartView: View { 15 - let heart: DoubleTapHeart 16 - var onComplete: () -> Void 14 + var heartScale: CGFloat = 0 15 + var ripple1Scale: CGFloat = 0.3 16 + var ripple1Opacity: Double = 0 17 + var ripple2Scale: CGFloat = 0.3 18 + var ripple2Opacity: Double = 0 19 + var ripple3Scale: CGFloat = 0.3 20 + var ripple3Opacity: Double = 0 21 + var isComplete = false 17 22 18 - @State private var heartScale: CGFloat = 0 19 - @State private var ripple1Scale: CGFloat = 0.3 20 - @State private var ripple1Opacity: Double = 0 21 - @State private var ripple2Scale: CGFloat = 0.3 22 - @State private var ripple2Opacity: Double = 0 23 - @State private var ripple3Scale: CGFloat = 0.3 24 - @State private var ripple3Opacity: Double = 0 25 - 26 - var body: some View { 27 - ZStack { 28 - Image(systemName: "heart") 29 - .font(.system(size: 80, weight: .light)) 30 - .foregroundStyle(Color("AccentColor")) 31 - .scaleEffect(ripple3Scale) 32 - .opacity(ripple3Opacity) 33 - .rotationEffect(.degrees(heart.rotation * 0.6)) 34 - 35 - Image(systemName: "heart") 36 - .font(.system(size: 80, weight: .ultraLight)) 37 - .foregroundStyle(Color("AccentColor")) 38 - .scaleEffect(ripple2Scale) 39 - .opacity(ripple2Opacity) 40 - .rotationEffect(.degrees(heart.rotation * 0.8)) 41 - 42 - Image(systemName: "heart") 43 - .font(.system(size: 80, weight: .thin)) 44 - .foregroundStyle(Color("AccentColor")) 45 - .scaleEffect(ripple1Scale) 46 - .opacity(ripple1Opacity) 47 - .rotationEffect(.degrees(heart.rotation * 0.9)) 48 - 49 - Image(systemName: "heart.fill") 50 - .font(.system(size: 80)) 51 - .foregroundStyle(Color("AccentColor")) 52 - .shadow(color: Color("AccentColor").opacity(0.4), radius: 12) 53 - .scaleEffect(heartScale) 54 - .opacity(heartScale > 1.2 ? 0 : 1) 55 - .rotationEffect(.degrees(heart.rotation)) 56 - } 57 - .position(x: heart.position.x, y: heart.position.y) 58 - .allowsHitTesting(false) 59 - .onAppear { animate() } 23 + init(position: CGPoint) { 24 + self.position = position 25 + self.rotation = Double.random(in: -20...20) 60 26 } 61 27 62 - private func animate() { 28 + func start() { 63 29 withAnimation(.spring(response: 0.25, dampingFraction: 0.5)) { 64 30 heartScale = 1 65 31 } ··· 80 46 try? await Task.sleep(for: .milliseconds(500)) 81 47 withAnimation(.easeInOut(duration: 0.4)) { heartScale = 1.6 } 82 48 try? await Task.sleep(for: .milliseconds(400)) 83 - onComplete() 49 + isComplete = true 84 50 } 85 51 } 86 52 } 87 53 54 + private struct DoubleTapHeartView: View { 55 + let state: HeartAnimationState 56 + 57 + var body: some View { 58 + ZStack { 59 + Image(systemName: "heart") 60 + .font(.system(size: 80, weight: .light)) 61 + .foregroundStyle(Color("AccentColor")) 62 + .scaleEffect(state.ripple3Scale) 63 + .opacity(state.ripple3Opacity) 64 + .rotationEffect(.degrees(state.rotation * 0.6)) 65 + 66 + Image(systemName: "heart") 67 + .font(.system(size: 80, weight: .ultraLight)) 68 + .foregroundStyle(Color("AccentColor")) 69 + .scaleEffect(state.ripple2Scale) 70 + .opacity(state.ripple2Opacity) 71 + .rotationEffect(.degrees(state.rotation * 0.8)) 72 + 73 + Image(systemName: "heart") 74 + .font(.system(size: 80, weight: .thin)) 75 + .foregroundStyle(Color("AccentColor")) 76 + .scaleEffect(state.ripple1Scale) 77 + .opacity(state.ripple1Opacity) 78 + .rotationEffect(.degrees(state.rotation * 0.9)) 79 + 80 + Image(systemName: "heart.fill") 81 + .font(.system(size: 80)) 82 + .foregroundStyle(Color("AccentColor")) 83 + .shadow(color: Color("AccentColor").opacity(0.4), radius: 12) 84 + .scaleEffect(state.heartScale) 85 + .opacity(state.heartScale > 1.2 ? 0 : 1) 86 + .rotationEffect(.degrees(state.rotation)) 87 + } 88 + .position(x: state.position.x, y: state.position.y) 89 + .allowsHitTesting(false) 90 + .onAppear { state.start() } 91 + } 92 + } 93 + 88 94 struct GalleryCardView: View { 89 95 @Environment(AuthManager.self) private var auth 96 + @Environment(StoryStatusCache.self) private var storyStatusCache 90 97 @Binding var gallery: GrainGallery 91 98 let client: XRPCClient 92 99 var onNavigate: () -> Void = {} 93 100 var onProfileTap: ((String) -> Void)? 94 101 var onHashtagTap: ((String) -> Void)? 102 + var onStoryTap: ((GrainStoryAuthor) -> Void)? 95 103 @State private var isFavoriting = false 96 104 @State private var currentPage = 0 97 105 @State private var showingAlt = false 98 - @State private var hearts: [DoubleTapHeart] = [] 106 + @State private var hearts: [HeartAnimationState] = [] 107 + @State private var showCopiedToast = false 108 + @State private var shareWiggle = false 109 + @State private var didLongPressShare = false 99 110 100 111 private var isFavorited: Bool { 101 112 gallery.viewer?.fav != nil ··· 110 121 VStack(alignment: .leading, spacing: 0) { 111 122 // Header — tappable for navigation 112 123 HStack(spacing: 8) { 113 - AvatarView(url: gallery.creator.avatar, size: 32) 124 + StoryRingView(hasStory: storyStatusCache.hasStory(for: gallery.creator.did), size: 32) { 125 + AvatarView(url: gallery.creator.avatar, size: 32) 126 + } 127 + .onTapGesture { 128 + if let author = storyStatusCache.author(for: gallery.creator.did) { 129 + onStoryTap?(author) 130 + } else { 131 + onProfileTap?(gallery.creator.did) 132 + } 133 + } 134 + .onLongPressGesture { 135 + onProfileTap?(gallery.creator.did) 136 + } 114 137 115 138 VStack(alignment: .leading, spacing: 0) { 116 139 HStack(spacing: 4) { ··· 232 255 233 256 // Double-tap heart animations 234 257 ForEach(hearts) { heart in 235 - DoubleTapHeartView(heart: heart) { 236 - hearts.removeAll { $0.id == heart.id } 237 - } 258 + DoubleTapHeartView(state: heart) 259 + .onChange(of: heart.isComplete) { 260 + hearts.removeAll { $0.isComplete } 261 + } 238 262 } 239 263 } 240 264 .frame(height: height) ··· 277 301 ShareLink(item: galleryShareURL) { 278 302 Image(systemName: "paperplane") 279 303 .font(.system(size: 20)) 304 + .rotationEffect(.degrees(shareWiggle ? -15 : 0)) 305 + .animation( 306 + shareWiggle 307 + ? .easeInOut(duration: 0.08).repeatCount(5, autoreverses: true) 308 + : .default, 309 + value: shareWiggle 310 + ) 280 311 } 281 312 .foregroundStyle(.secondary) 313 + .disabled(didLongPressShare) 314 + .simultaneousGesture( 315 + LongPressGesture(minimumDuration: 0.5) 316 + .onEnded { _ in 317 + didLongPressShare = true 318 + UIPasteboard.general.url = galleryShareURL 319 + let generator = UIImpactFeedbackGenerator(style: .medium) 320 + generator.impactOccurred() 321 + shareWiggle = true 322 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 323 + shareWiggle = false 324 + } 325 + showCopiedToast = true 326 + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 327 + showCopiedToast = false 328 + } 329 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { 330 + didLongPressShare = false 331 + } 332 + } 333 + ) 282 334 283 335 Spacer() 284 336 } ··· 316 368 .padding(.top, 8) 317 369 .padding(.bottom, 16) 318 370 } 371 + .overlay { 372 + if showCopiedToast { 373 + HStack(spacing: 6) { 374 + Image(systemName: "doc.on.doc.fill") 375 + .font(.caption) 376 + Text("Link copied") 377 + .font(.subheadline.weight(.medium)) 378 + } 379 + .padding(.horizontal, 16) 380 + .padding(.vertical, 10) 381 + .background(.ultraThinMaterial, in: Capsule()) 382 + .shadow(color: .black.opacity(0.15), radius: 8, y: 4) 383 + .transition(.scale.combined(with: .opacity)) 384 + } 385 + } 386 + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: showCopiedToast) 319 387 } 320 388 321 389 private func doubleTapLike(at point: CGPoint) { 322 - hearts.append(DoubleTapHeart( 323 - position: point, 324 - rotation: Double.random(in: -20...20) 325 - )) 390 + hearts.append(HeartAnimationState(position: point)) 326 391 327 392 guard !isFavorited, !isFavoriting else { return } 328 393 isFavoriting = true ··· 371 436 } 372 437 } 373 438 } 439 +
+28 -3
Grain/Views/Feed/FeedView.swift
··· 2 2 3 3 struct FeedView: View { 4 4 @Environment(AuthManager.self) private var auth 5 + @Environment(StoryStatusCache.self) private var storyStatusCache 5 6 @State private var prefsViewModel: FeedPreferencesViewModel 6 7 @State private var storyViewModel: StoryStripViewModel 7 8 @State private var showStoryViewer = false ··· 38 39 showStoryViewer = true 39 40 }, 40 41 onStoryCreateTap: { showStoryCreate = true }, 41 - onRefresh: { 42 - await storyViewModel.load(auth: auth.authContext()) 42 + onRefresh: { [storyStatusCache] in 43 + await storyViewModel.load(auth: auth.authContext(), storyStatusCache: storyStatusCache) 43 44 }, 44 45 prefsViewModel: prefsViewModel 45 46 ) ··· 58 59 } 59 60 .task { 60 61 await prefsViewModel.loadIfNeeded(auth: auth.authContext()) 61 - await storyViewModel.load(auth: auth.authContext()) 62 + await storyViewModel.load(auth: auth.authContext(), storyStatusCache: storyStatusCache) 62 63 } 63 64 .onAppear { 64 65 Task { await prefsViewModel.refresh(auth: auth.authContext()) } ··· 68 69 authors: storyViewModel.authors, 69 70 startIndex: storyViewerStartIndex, 70 71 client: client, 72 + onProfileTap: { did in 73 + showStoryViewer = false 74 + deepLinkProfileDid = did 75 + }, 71 76 onDismiss: { showStoryViewer = false } 72 77 ) 73 78 } ··· 86 91 StoryViewer( 87 92 authors: [author], 88 93 client: client, 94 + onProfileTap: { did in 95 + deepLinkStoryAuthor = nil 96 + deepLinkProfileDid = did 97 + }, 89 98 onDismiss: { deepLinkStoryAuthor = nil } 90 99 ) 91 100 .environment(auth) ··· 199 208 @State private var selectedHashtag: String? 200 209 @State private var deletedGalleryUri: String? 201 210 @State private var zoomState = ImageZoomState() 211 + @State private var cardStoryAuthor: GrainStoryAuthor? 202 212 let client: XRPCClient 203 213 let storyAuthors: [GrainStoryAuthor] 204 214 let userAvatar: String? ··· 225 235 authors: storyAuthors, 226 236 userAvatar: userAvatar, 227 237 onAuthorTap: onStoryAuthorTap, 238 + onAuthorLongPress: { did in selectedProfileDid = did }, 228 239 onCreateTap: onStoryCreateTap 229 240 ) 230 241 ··· 235 246 selectedProfileDid = did 236 247 }, onHashtagTap: { tag in 237 248 selectedHashtag = tag 249 + }, onStoryTap: { author in 250 + cardStoryAuthor = author 238 251 }) 239 252 .onAppear { 240 253 if gallery.id == viewModel.galleries.last?.id { ··· 266 279 } 267 280 .navigationDestination(item: $selectedHashtag) { tag in 268 281 HashtagFeedView(client: client, tag: tag) 282 + } 283 + .fullScreenCover(item: $cardStoryAuthor) { author in 284 + StoryViewer( 285 + authors: [author], 286 + client: client, 287 + onProfileTap: { did in 288 + cardStoryAuthor = nil 289 + selectedProfileDid = did 290 + }, 291 + onDismiss: { cardStoryAuthor = nil } 292 + ) 293 + .environment(auth) 269 294 } 270 295 .task { 271 296 if viewModel.galleries.isEmpty {
+19
Grain/Views/Feed/HashtagFeedView.swift
··· 9 9 @State private var selectedUri: String? 10 10 @State private var selectedProfileDid: String? 11 11 @State private var selectedHashtag: String? 12 + @State private var zoomState = ImageZoomState() 13 + @State private var cardStoryAuthor: GrainStoryAuthor? 12 14 13 15 let client: XRPCClient 14 16 let tag: String ··· 25 27 selectedProfileDid = did 26 28 }, onHashtagTap: { tag in 27 29 selectedHashtag = tag 30 + }, onStoryTap: { author in 31 + cardStoryAuthor = author 28 32 }) 29 33 .onAppear { 30 34 if gallery.id == galleries.last?.id { ··· 39 43 } 40 44 } 41 45 } 46 + .environment(zoomState) 47 + .modifier(ImageZoomOverlay(zoomState: zoomState)) 42 48 .navigationTitle("#\(tag)") 43 49 .navigationBarTitleDisplayMode(.inline) 44 50 .toolbar { ··· 54 60 Image(systemName: "ellipsis") 55 61 .font(.system(size: 16, weight: .medium)) 56 62 } 63 + .tint(.primary) 57 64 } 58 65 } 59 66 .task { ··· 67 74 } 68 75 .navigationDestination(item: $selectedHashtag) { tag in 69 76 HashtagFeedView(client: client, tag: tag) 77 + } 78 + .fullScreenCover(item: $cardStoryAuthor) { author in 79 + StoryViewer( 80 + authors: [author], 81 + client: client, 82 + onProfileTap: { did in 83 + cardStoryAuthor = nil 84 + selectedProfileDid = did 85 + }, 86 + onDismiss: { cardStoryAuthor = nil } 87 + ) 88 + .environment(auth) 70 89 } 71 90 .task { 72 91 if galleries.isEmpty {
+30 -2
Grain/Views/Notifications/NotificationsView.swift
··· 6 6 var viewModel: NotificationsViewModel 7 7 @State private var selectedGalleryUri: String? 8 8 @State private var selectedProfileDid: String? 9 + @State private var cardStoryAuthor: GrainStoryAuthor? 9 10 let client: XRPCClient 10 11 11 12 init(client: XRPCClient, viewModel: NotificationsViewModel) { ··· 19 20 ForEach(viewModel.notifications) { notification in 20 21 NotificationRow(notification: notification, onProfileTap: { did in 21 22 selectedProfileDid = did 23 + }, onStoryTap: { author in 24 + cardStoryAuthor = author 22 25 }) 23 26 .contentShape(Rectangle()) 24 27 .onTapGesture { ··· 61 64 .navigationDestination(item: $selectedProfileDid) { did in 62 65 ProfileView(client: client, did: did) 63 66 } 67 + .fullScreenCover(item: $cardStoryAuthor) { author in 68 + StoryViewer( 69 + authors: [author], 70 + client: client, 71 + onProfileTap: { did in 72 + cardStoryAuthor = nil 73 + selectedProfileDid = did 74 + }, 75 + onDismiss: { cardStoryAuthor = nil } 76 + ) 77 + .environment(auth) 78 + } 64 79 .task { 65 80 if viewModel.notifications.isEmpty { 66 81 await viewModel.loadInitial(auth: auth.authContext()) ··· 72 87 } 73 88 74 89 struct NotificationRow: View { 90 + @Environment(StoryStatusCache.self) private var storyStatusCache 75 91 let notification: GrainNotification 76 92 var onProfileTap: ((String) -> Void)? 93 + var onStoryTap: ((GrainStoryAuthor) -> Void)? 77 94 78 95 var body: some View { 79 96 HStack(alignment: .top, spacing: 12) { 80 - AvatarView(url: notification.author.avatar, size: 36) 81 - .onTapGesture { onProfileTap?(notification.author.did) } 97 + StoryRingView(hasStory: storyStatusCache.hasStory(for: notification.author.did), size: 36) { 98 + AvatarView(url: notification.author.avatar, size: 36) 99 + } 100 + .onTapGesture { 101 + if let author = storyStatusCache.author(for: notification.author.did) { 102 + onStoryTap?(author) 103 + } else { 104 + onProfileTap?(notification.author.did) 105 + } 106 + } 107 + .onLongPressGesture { 108 + onProfileTap?(notification.author.did) 109 + } 82 110 83 111 VStack(alignment: .leading, spacing: 4) { 84 112 Text("\(Text(notification.author.displayName ?? notification.author.handle).font(.subheadline.bold())) \(Text(reasonText).font(.subheadline).foregroundStyle(.secondary))")
+27 -1
Grain/Views/Profile/FollowListView.swift
··· 17 17 @State private var isLoading = false 18 18 @State private var hasLoaded = false 19 19 @State private var selectedProfileDid: String? 20 + @State private var cardStoryAuthor: GrainStoryAuthor? 21 + @Environment(StoryStatusCache.self) private var storyStatusCache 20 22 21 23 private var title: String { 22 24 mode == .followers ? "Followers" : "Following" ··· 72 74 .navigationDestination(item: $selectedProfileDid) { did in 73 75 ProfileView(client: client, did: did) 74 76 } 77 + .fullScreenCover(item: $cardStoryAuthor) { author in 78 + StoryViewer( 79 + authors: [author], 80 + client: client, 81 + onProfileTap: { did in 82 + cardStoryAuthor = nil 83 + selectedProfileDid = did 84 + }, 85 + onDismiss: { cardStoryAuthor = nil } 86 + ) 87 + .environment(auth) 88 + } 75 89 .task { 76 90 await reload() 77 91 } ··· 85 99 @ViewBuilder 86 100 private func rowContent(item: FollowListItem) -> some View { 87 101 HStack(alignment: .center, spacing: 14) { 88 - AvatarView(url: item.avatar, size: 50) 102 + StoryRingView(hasStory: storyStatusCache.hasStory(for: item.did), size: 50) { 103 + AvatarView(url: item.avatar, size: 50) 104 + } 105 + .onTapGesture { 106 + if let author = storyStatusCache.author(for: item.did) { 107 + cardStoryAuthor = author 108 + } else { 109 + selectedProfileDid = item.did 110 + } 111 + } 112 + .onLongPressGesture { 113 + selectedProfileDid = item.did 114 + } 89 115 VStack(alignment: .leading, spacing: 2) { 90 116 if let displayName = item.displayName, !displayName.isEmpty { 91 117 Text(displayName)
+23 -1
Grain/Views/Profile/ProfileView.swift
··· 16 16 @State private var selectedHashtag: String? 17 17 @State private var deletedGalleryUri: String? 18 18 @State private var viewMode: ProfileViewMode = .grid 19 + @State private var zoomState = ImageZoomState() 20 + @State private var cardStoryAuthor: GrainStoryAuthor? 19 21 let client: XRPCClient 20 22 let did: String 21 23 var isRoot = false ··· 183 185 client: client, 184 186 onNavigate: { selectedGalleryUri = gallery.uri }, 185 187 onProfileTap: { did in selectedProfileDid = did }, 186 - onHashtagTap: { tag in selectedHashtag = tag } 188 + onHashtagTap: { tag in selectedHashtag = tag }, 189 + onStoryTap: { author in cardStoryAuthor = author } 187 190 ) 188 191 .onAppear { 189 192 if gallery.id == viewModel.galleries.last?.id { ··· 199 202 .padding(.top, 100) 200 203 } 201 204 } 205 + .environment(zoomState) 206 + .modifier(ImageZoomOverlay(zoomState: zoomState)) 202 207 .navigationTitle("") 203 208 .navigationBarTitleDisplayMode(.inline) 204 209 .toolbar { ··· 211 216 } label: { 212 217 Image(systemName: "gearshape") 213 218 } 219 + .tint(.primary) 214 220 } 215 221 } 216 222 } ··· 233 239 )], 234 240 startIndex: 0, 235 241 client: client, 242 + onProfileTap: { did in 243 + showStoryViewer = false 244 + selectedProfileDid = did 245 + }, 236 246 onDismiss: { showStoryViewer = false } 237 247 ) 238 248 } 249 + } 250 + .fullScreenCover(item: $cardStoryAuthor) { author in 251 + StoryViewer( 252 + authors: [author], 253 + client: client, 254 + onProfileTap: { did in 255 + cardStoryAuthor = nil 256 + selectedProfileDid = did 257 + }, 258 + onDismiss: { cardStoryAuthor = nil } 259 + ) 260 + .environment(auth) 239 261 } 240 262 .fullScreenCover(isPresented: $showAvatarOverlay) { 241 263 if let avatar = viewModel.profile?.avatar {
+22 -1
Grain/Views/Search/SearchView.swift
··· 2 2 3 3 struct SearchView: View { 4 4 @Environment(AuthManager.self) private var auth 5 + @Environment(StoryStatusCache.self) private var storyStatusCache 5 6 @State private var viewModel: SearchViewModel 6 7 @State private var searchText = "" 7 8 @State private var searchNavigationUri: String? 8 9 @State private var selectedProfileDid: String? 9 10 @State private var selectedHashtag: String? 11 + @State private var zoomState = ImageZoomState() 12 + @State private var cardStoryAuthor: GrainStoryAuthor? 10 13 let client: XRPCClient 11 14 12 15 init(client: XRPCClient) { ··· 31 34 selectedProfileDid = did 32 35 }, onHashtagTap: { tag in 33 36 selectedHashtag = tag 37 + }, onStoryTap: { author in 38 + cardStoryAuthor = author 34 39 }) 35 40 } 36 41 case .profiles: ··· 39 44 selectedProfileDid = profile.did 40 45 } label: { 41 46 HStack { 42 - AvatarView(url: profile.avatar, size: 40) 47 + StoryRingView(hasStory: storyStatusCache.hasStory(for: profile.did), size: 40) { 48 + AvatarView(url: profile.avatar, size: 40) 49 + } 43 50 VStack(alignment: .leading) { 44 51 Text(profile.displayName ?? profile.handle ?? "") 45 52 .font(.subheadline.bold()) ··· 59 66 } 60 67 .padding(.top) 61 68 } 69 + .environment(zoomState) 70 + .modifier(ImageZoomOverlay(zoomState: zoomState)) 62 71 } 63 72 } 64 73 .navigationTitle("Search") ··· 87 96 } 88 97 .navigationDestination(item: $selectedHashtag) { tag in 89 98 HashtagFeedView(client: client, tag: tag) 99 + } 100 + .fullScreenCover(item: $cardStoryAuthor) { author in 101 + StoryViewer( 102 + authors: [author], 103 + client: client, 104 + onProfileTap: { did in 105 + cardStoryAuthor = nil 106 + selectedProfileDid = did 107 + }, 108 + onDismiss: { cardStoryAuthor = nil } 109 + ) 110 + .environment(auth) 90 111 } 91 112 } 92 113 }
+2 -2
Grain/Views/Stories/StoryRingView.swift
··· 20 20 startPoint: .topLeading, 21 21 endPoint: .bottomTrailing 22 22 ), 23 - lineWidth: 2.5 23 + lineWidth: size <= 28 ? 1.5 : size <= 40 ? 2.5 : 3.5 24 24 ) 25 - .frame(width: size + 6, height: size + 6) 25 + .frame(width: size + (size <= 28 ? 4 : size <= 40 ? 6 : 8), height: size + (size <= 28 ? 4 : size <= 40 ? 6 : 8)) 26 26 } 27 27 } 28 28 }
+3 -1
Grain/Views/Stories/StoryStripView.swift
··· 4 4 let authors: [GrainStoryAuthor] 5 5 let userAvatar: String? 6 6 let onAuthorTap: (GrainStoryAuthor, Int) -> Void 7 + var onAuthorLongPress: ((String) -> Void)? 7 8 let onCreateTap: () -> Void 8 9 9 - private let avatarSize: CGFloat = 56 10 + private let avatarSize: CGFloat = 68 10 11 11 12 var body: some View { 12 13 ScrollView(.horizontal, showsIndicators: false) { ··· 40 41 .frame(width: avatarSize + 8) 41 42 } 42 43 .onTapGesture { onAuthorTap(author, index) } 44 + .onLongPressGesture { onAuthorLongPress?(author.profile.did) } 43 45 } 44 46 } 45 47 .padding(.horizontal)