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: rich text with tappable links, mentions, hashtags + UI polish

Add RichTextView component using AttributedString with custom URL schemes
for @mentions and #hashtags. Links open Safari, mentions navigate to
profiles, hashtags load hashtag feed. Wire up navigation across all views.
Fix gallery grid on profile navigating to wrong destination. Adapt logo
color for light/dark mode. Bump heart icon size to match comment icon.

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

+333 -19
+15 -11
Grain/Views/Components/GalleryCardView.swift
··· 10 10 let client: XRPCClient 11 11 var onNavigate: () -> Void = {} 12 12 var onProfileTap: ((String) -> Void)? 13 + var onHashtagTap: ((String) -> Void)? 13 14 @State private var isFavoriting = false 14 15 @State private var currentPage = 0 15 16 @State private var showingAlt = false ··· 164 165 } label: { 165 166 HStack(spacing: 5) { 166 167 Image(systemName: isFavorited ? "heart.fill" : "heart") 167 - .font(.system(size: 20)) 168 + .font(.system(size: 22)) 168 169 Text("\(gallery.favCount ?? 0)") 169 170 } 170 171 } ··· 188 189 .padding(.top, 12) 189 190 .padding(.bottom, 4) 190 191 191 - // Title & description — tappable for navigation 192 - VStack(alignment: .leading, spacing: 2) { 192 + // Title & description 193 + VStack(alignment: .leading, spacing: 4) { 193 194 Text(gallery.title ?? "") 194 195 .font(.subheadline.weight(.semibold)) 195 196 .lineLimit(1) 197 + .contentShape(Rectangle()) 198 + .onTapGesture { onNavigate() } 196 199 197 200 if let description = gallery.description, !description.isEmpty { 198 - Text(description) 199 - .font(.caption) 200 - .foregroundStyle(.secondary) 201 - .lineLimit(2) 201 + RichTextView( 202 + text: description, 203 + color: .secondary, 204 + onMentionTap: onProfileTap, 205 + onHashtagTap: onHashtagTap 206 + ) 207 + .lineLimit(2) 202 208 } 203 209 } 204 210 .padding(.horizontal, 12) 205 - .padding(.top, 4) 206 - .padding(.bottom, 12) 207 - .contentShape(Rectangle()) 208 - .onTapGesture { onNavigate() } 211 + .padding(.top, 8) 212 + .padding(.bottom, 16) 209 213 } 210 214 } 211 215
+188
Grain/Views/Components/RichTextView.swift
··· 1 + import SwiftUI 2 + 3 + /// Renders text with tappable links, mentions, and hashtags. 4 + /// Uses facets if provided, otherwise falls back to regex parsing. 5 + struct RichTextView: View { 6 + let text: String 7 + var facets: [Facet]? 8 + var font: Font = .subheadline 9 + var color: Color = .primary 10 + var onMentionTap: ((String) -> Void)? 11 + var onHashtagTap: ((String) -> Void)? 12 + 13 + private var attributedString: AttributedString { 14 + let segments: [Segment] 15 + if let facets, !facets.isEmpty { 16 + segments = segmentsFromFacets(text: text, facets: facets) 17 + } else { 18 + segments = segmentsFromRegex(text: text) 19 + } 20 + 21 + var result = AttributedString() 22 + for segment in segments { 23 + var part: AttributedString 24 + switch segment { 25 + case .plain(let str): 26 + part = AttributedString(str) 27 + part.foregroundColor = color 28 + case .link(let str, let url): 29 + part = AttributedString(str) 30 + if let linkURL = URL(string: url) { 31 + part.link = linkURL 32 + } 33 + part.foregroundColor = Color("AccentColor") 34 + case .mention(let str, let did): 35 + part = AttributedString(str) 36 + let encoded = did.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? did 37 + part.link = URL(string: "grain-mention://\(encoded)") 38 + part.foregroundColor = Color("AccentColor") 39 + case .hashtag(let str, let tag): 40 + part = AttributedString(str) 41 + let encoded = tag.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? tag 42 + part.link = URL(string: "grain-hashtag://\(encoded)") 43 + part.foregroundColor = Color("AccentColor") 44 + } 45 + part.font = font 46 + result.append(part) 47 + } 48 + return result 49 + } 50 + 51 + var body: some View { 52 + Text(attributedString) 53 + .tint(Color("AccentColor")) 54 + .environment(\.openURL, OpenURLAction { url in 55 + if url.scheme == "grain-mention" { 56 + let did = url.host()?.removingPercentEncoding ?? "" 57 + if !did.isEmpty { 58 + onMentionTap?(did) 59 + } 60 + return .handled 61 + } 62 + if url.scheme == "grain-hashtag" { 63 + let tag = url.host()?.removingPercentEncoding ?? "" 64 + if !tag.isEmpty { 65 + onHashtagTap?(tag) 66 + } 67 + return .handled 68 + } 69 + // Regular URLs — open in Safari 70 + return .systemAction 71 + }) 72 + } 73 + 74 + // MARK: - Facet-based parsing 75 + 76 + private func segmentsFromFacets(text: String, facets: [Facet]) -> [Segment] { 77 + let utf8 = Array(text.utf8) 78 + let sorted = facets.sorted { $0.index.byteStart < $1.index.byteStart } 79 + var segments: [Segment] = [] 80 + var cursor = 0 81 + 82 + for facet in sorted { 83 + let start = facet.index.byteStart 84 + let end = min(facet.index.byteEnd, utf8.count) 85 + guard start >= cursor, end > start else { continue } 86 + 87 + if start > cursor { 88 + let plain = String(bytes: utf8[cursor..<start], encoding: .utf8) ?? "" 89 + segments.append(.plain(plain)) 90 + } 91 + 92 + let slice = String(bytes: utf8[start..<end], encoding: .utf8) ?? "" 93 + if let feature = facet.features.first { 94 + switch feature { 95 + case .link(let uri): 96 + segments.append(.link(slice, url: uri)) 97 + case .mention(let did): 98 + segments.append(.mention(slice, did: did)) 99 + case .tag(let tag): 100 + segments.append(.hashtag(slice, tag: tag)) 101 + } 102 + } else { 103 + segments.append(.plain(slice)) 104 + } 105 + cursor = end 106 + } 107 + 108 + if cursor < utf8.count { 109 + let remaining = String(bytes: utf8[cursor...], encoding: .utf8) ?? "" 110 + segments.append(.plain(remaining)) 111 + } 112 + 113 + return segments 114 + } 115 + 116 + // MARK: - Regex fallback 117 + 118 + private func segmentsFromRegex(text: String) -> [Segment] { 119 + let urlPattern = #"https?://[^\s<>\[\]()]+"# 120 + let mentionPattern = #"@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"# 121 + let hashtagPattern = #"#([a-zA-Z][a-zA-Z0-9_]*)"# 122 + 123 + struct Match { 124 + let range: Range<String.Index> 125 + let segment: Segment 126 + } 127 + 128 + var matches: [Match] = [] 129 + 130 + if let regex = try? NSRegularExpression(pattern: urlPattern) { 131 + let nsRange = NSRange(text.startIndex..., in: text) 132 + for m in regex.matches(in: text, range: nsRange) { 133 + if let range = Range(m.range, in: text) { 134 + let str = String(text[range]) 135 + matches.append(Match(range: range, segment: .link(str, url: str))) 136 + } 137 + } 138 + } 139 + 140 + if let regex = try? NSRegularExpression(pattern: mentionPattern) { 141 + let nsRange = NSRange(text.startIndex..., in: text) 142 + for m in regex.matches(in: text, range: nsRange) { 143 + if let range = Range(m.range, in: text) { 144 + if matches.contains(where: { $0.range.overlaps(range) }) { continue } 145 + let str = String(text[range]) 146 + matches.append(Match(range: range, segment: .mention(str, did: String(str.dropFirst())))) 147 + } 148 + } 149 + } 150 + 151 + if let regex = try? NSRegularExpression(pattern: hashtagPattern) { 152 + let nsRange = NSRange(text.startIndex..., in: text) 153 + for m in regex.matches(in: text, range: nsRange) { 154 + if let range = Range(m.range, in: text) { 155 + if matches.contains(where: { $0.range.overlaps(range) }) { continue } 156 + let str = String(text[range]) 157 + matches.append(Match(range: range, segment: .hashtag(str, tag: String(str.dropFirst())))) 158 + } 159 + } 160 + } 161 + 162 + matches.sort { $0.range.lowerBound < $1.range.lowerBound } 163 + 164 + var segments: [Segment] = [] 165 + var cursor = text.startIndex 166 + 167 + for match in matches { 168 + if match.range.lowerBound > cursor { 169 + segments.append(.plain(String(text[cursor..<match.range.lowerBound]))) 170 + } 171 + segments.append(match.segment) 172 + cursor = match.range.upperBound 173 + } 174 + 175 + if cursor < text.endIndex { 176 + segments.append(.plain(String(text[cursor...]))) 177 + } 178 + 179 + return segments 180 + } 181 + } 182 + 183 + private enum Segment { 184 + case plain(String) 185 + case link(String, url: String) 186 + case mention(String, did: String) 187 + case hashtag(String, tag: String) 188 + }
+8 -1
Grain/Views/Feed/FeedView.swift
··· 50 50 .font(.caption2.weight(.bold)) 51 51 } 52 52 } 53 - .foregroundStyle(.white) 53 + .foregroundColor(.primary) 54 54 } 55 + .tint(.primary) 55 56 } 56 57 } 57 58 .task { ··· 81 82 @State private var viewModel: FeedViewModel 82 83 @State private var selectedUri: String? 83 84 @State private var selectedProfileDid: String? 85 + @State private var selectedHashtag: String? 84 86 let client: XRPCClient 85 87 86 88 init(client: XRPCClient, pinnedFeed: PinnedFeed, userDID: String? = nil) { ··· 96 98 selectedUri = gallery.uri 97 99 }, onProfileTap: { did in 98 100 selectedProfileDid = did 101 + }, onHashtagTap: { tag in 102 + selectedHashtag = tag 99 103 }) 100 104 .onAppear { 101 105 if gallery.id == viewModel.galleries.last?.id { ··· 120 124 } 121 125 .navigationDestination(item: $selectedProfileDid) { did in 122 126 ProfileView(client: client, did: did) 127 + } 128 + .navigationDestination(item: $selectedHashtag) { tag in 129 + HashtagFeedView(client: client, tag: tag) 123 130 } 124 131 .task { 125 132 if viewModel.galleries.isEmpty {
+79
Grain/Views/Feed/HashtagFeedView.swift
··· 1 + import SwiftUI 2 + 3 + struct HashtagFeedView: View { 4 + @Environment(AuthManager.self) private var auth 5 + @State private var galleries: [GrainGallery] = [] 6 + @State private var cursor: String? 7 + @State private var isLoading = false 8 + @State private var selectedUri: String? 9 + @State private var selectedProfileDid: String? 10 + @State private var selectedHashtag: String? 11 + 12 + let client: XRPCClient 13 + let tag: String 14 + 15 + var body: some View { 16 + ScrollView { 17 + LazyVStack(spacing: 0) { 18 + ForEach($galleries) { $gallery in 19 + GalleryCardView(gallery: $gallery, client: client, onNavigate: { 20 + selectedUri = gallery.uri 21 + }, onProfileTap: { did in 22 + selectedProfileDid = did 23 + }, onHashtagTap: { tag in 24 + selectedHashtag = tag 25 + }) 26 + .onAppear { 27 + if gallery.id == galleries.last?.id { 28 + Task { await loadMore() } 29 + } 30 + } 31 + 32 + Divider() 33 + } 34 + 35 + if isLoading { 36 + ProgressView() 37 + .padding() 38 + } 39 + } 40 + } 41 + .navigationTitle("#\(tag)") 42 + .navigationBarTitleDisplayMode(.inline) 43 + .navigationDestination(item: $selectedUri) { uri in 44 + GalleryDetailView(client: client, galleryUri: uri) 45 + } 46 + .navigationDestination(item: $selectedProfileDid) { did in 47 + ProfileView(client: client, did: did) 48 + } 49 + .navigationDestination(item: $selectedHashtag) { tag in 50 + HashtagFeedView(client: client, tag: tag) 51 + } 52 + .task { 53 + if galleries.isEmpty { 54 + await loadInitial() 55 + } 56 + } 57 + } 58 + 59 + private func loadInitial() async { 60 + isLoading = true 61 + do { 62 + let response = try await client.getFeed(feed: "hashtag", tag: tag, auth: auth.authContext()) 63 + galleries = response.items ?? [] 64 + cursor = response.cursor 65 + } catch {} 66 + isLoading = false 67 + } 68 + 69 + private func loadMore() async { 70 + guard !isLoading, let cursor else { return } 71 + isLoading = true 72 + do { 73 + let response = try await client.getFeed(feed: "hashtag", cursor: cursor, tag: tag, auth: auth.authContext()) 74 + galleries.append(contentsOf: response.items ?? []) 75 + self.cursor = response.cursor 76 + } catch {} 77 + isLoading = false 78 + } 79 + }
+16 -2
Grain/Views/Gallery/GalleryDetailView.swift
··· 5 5 @Environment(AuthManager.self) private var auth 6 6 @State private var viewModel: GalleryDetailViewModel 7 7 @State private var selectedProfileDid: String? 8 + @State private var selectedHashtag: String? 8 9 @State private var commentText = "" 9 10 @State private var isPostingComment = false 10 11 @State private var replyingTo: GrainComment? ··· 41 42 client: client, 42 43 onProfileTap: { did in 43 44 selectedProfileDid = did 45 + }, 46 + onHashtagTap: { tag in 47 + selectedHashtag = tag 44 48 } 45 49 ) 46 50 ··· 97 101 isOwn: thread.root.author.did == auth.userDID, 98 102 isReply: false, 99 103 onProfileTap: { did in selectedProfileDid = did }, 104 + onHashtagTap: { tag in selectedHashtag = tag }, 100 105 onReply: { startReply(to: thread.root) }, 101 106 onDelete: { Task { await deleteComment(thread.root) } } 102 107 ) ··· 107 112 isOwn: reply.author.did == auth.userDID, 108 113 isReply: true, 109 114 onProfileTap: { did in selectedProfileDid = did }, 115 + onHashtagTap: { tag in selectedHashtag = tag }, 110 116 onDelete: { Task { await deleteComment(reply) } } 111 117 ) 112 118 } ··· 122 128 .navigationBarTitleDisplayMode(.inline) 123 129 .navigationDestination(item: $selectedProfileDid) { did in 124 130 ProfileView(client: client, did: did) 131 + } 132 + .navigationDestination(item: $selectedHashtag) { tag in 133 + HashtagFeedView(client: client, tag: tag) 125 134 } 126 135 .task { 127 136 await viewModel.load(uri: galleryUri, auth: auth.authContext()) ··· 177 186 var isOwn: Bool = false 178 187 var isReply: Bool = false 179 188 var onProfileTap: ((String) -> Void)? 189 + var onHashtagTap: ((String) -> Void)? 180 190 var onReply: (() -> Void)? 181 191 var onDelete: (() -> Void)? 182 192 ··· 193 203 .font(.caption) 194 204 .foregroundStyle(.secondary) 195 205 } 196 - Text(comment.text) 197 - .font(.subheadline) 206 + RichTextView( 207 + text: comment.text, 208 + facets: comment.facets, 209 + onMentionTap: onProfileTap, 210 + onHashtagTap: onHashtagTap 211 + ) 198 212 199 213 // Actions 200 214 HStack(spacing: 16) {
+21 -5
Grain/Views/Profile/ProfileView.swift
··· 4 4 struct ProfileView: View { 5 5 @Environment(AuthManager.self) private var auth 6 6 @State private var viewModel: ProfileDetailViewModel 7 + @State private var selectedGalleryUri: String? 8 + @State private var selectedProfileDid: String? 9 + @State private var selectedHashtag: String? 7 10 let client: XRPCClient 8 11 let did: String 9 12 var isRoot = false ··· 66 69 } 67 70 68 71 if let description = profile.description, !description.isEmpty { 69 - Text(description) 70 - .font(.body) 71 - .padding(.horizontal) 72 + RichTextView( 73 + text: description, 74 + font: .body, 75 + onMentionTap: { did in selectedProfileDid = did }, 76 + onHashtagTap: { tag in selectedHashtag = tag } 77 + ) 78 + .padding(.horizontal) 72 79 } 73 80 74 81 // Gallery grid ··· 78 85 GridItem(.flexible(), spacing: 2) 79 86 ], spacing: 2) { 80 87 ForEach(viewModel.galleries) { gallery in 81 - NavigationLink(value: gallery.uri) { 88 + Button { 89 + selectedGalleryUri = gallery.uri 90 + } label: { 82 91 Color.clear 83 92 .aspectRatio(3.0/4.0, contentMode: .fit) 84 93 .overlay { ··· 96 105 } 97 106 .clipped() 98 107 } 108 + .buttonStyle(.plain) 99 109 .onAppear { 100 110 if gallery.id == viewModel.galleries.last?.id { 101 111 Task { await viewModel.loadMoreGalleries(did: did, auth: auth.authContext()) } ··· 122 132 } 123 133 } 124 134 } 125 - .navigationDestination(for: String.self) { uri in 135 + .navigationDestination(item: $selectedGalleryUri) { uri in 126 136 GalleryDetailView(client: client, galleryUri: uri) 137 + } 138 + .navigationDestination(item: $selectedProfileDid) { did in 139 + ProfileView(client: client, did: did) 140 + } 141 + .navigationDestination(item: $selectedHashtag) { tag in 142 + HashtagFeedView(client: client, tag: tag) 127 143 } 128 144 .task { 129 145 await viewModel.load(did: did, auth: auth.authContext())
+6
Grain/Views/Search/SearchView.swift
··· 6 6 @State private var viewModel: SearchViewModel 7 7 @State private var searchNavigationUri: String? 8 8 @State private var selectedProfileDid: String? 9 + @State private var selectedHashtag: String? 9 10 let client: XRPCClient 10 11 11 12 init(client: XRPCClient) { ··· 93 94 searchNavigationUri = gallery.uri 94 95 }, onProfileTap: { did in 95 96 selectedProfileDid = did 97 + }, onHashtagTap: { tag in 98 + selectedHashtag = tag 96 99 }) 97 100 } 98 101 case .profiles: ··· 141 144 } 142 145 .navigationDestination(item: $selectedProfileDid) { did in 143 146 ProfileView(client: client, did: did) 147 + } 148 + .navigationDestination(item: $selectedHashtag) { tag in 149 + HashtagFeedView(client: client, tag: tag) 144 150 } 145 151 } 146 152 }