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 #9 from grainsocial/feat/profile-updates

feat: profile updates — archives, favorites, zoom transitions

authored by

Chad Miller and committed by
GitHub
104e775a e24e95b8

+361 -65
+11
Grain/API/Endpoints/FeedEndpoints.swift
··· 125 125 try await procedure("dev.hatk.putPreference", input: Input(key: "includeLocation", value: value), auth: auth) 126 126 } 127 127 128 + func getActorFavorites( 129 + actor: String, 130 + limit: Int = 30, 131 + cursor: String? = nil, 132 + auth: AuthContext? = nil 133 + ) async throws -> GetFeedResponse { 134 + var params = ["actor": actor, "limit": String(limit)] 135 + if let cursor { params["cursor"] = cursor } 136 + return try await query("social.grain.unspecced.getActorFavorites", params: params, auth: auth, as: GetFeedResponse.self) 137 + } 138 + 128 139 func searchGalleries( 129 140 query queryString: String, 130 141 limit: Int = 30,
+54
Grain/ViewModels/ProfileDetailViewModel.swift
··· 6 6 var profile: GrainProfileDetailed? 7 7 var galleries: [GrainGallery] = [] 8 8 var stories: [GrainStory] = [] 9 + var archivedStories: [GrainStory] = [] 10 + var favoriteGalleries: [GrainGallery] = [] 9 11 var knownFollowers: [FollowerItem] = [] 10 12 var isLoading = false 11 13 var error: Error? 12 14 13 15 private var galleryCursor: String? 14 16 private var hasMoreGalleries = true 17 + private var archiveCursor: String? 18 + private var hasMoreArchive = true 19 + private var archiveLoaded = false 20 + private var favoritesCursor: String? 21 + private var hasMoreFavorites = true 22 + private var favoritesLoaded = false 15 23 private let client: XRPCClient 16 24 17 25 init(client: XRPCClient) { ··· 60 68 } catch { 61 69 self.error = error 62 70 } 71 + isLoading = false 72 + } 73 + 74 + func loadStoryArchive(did: String, auth: AuthContext? = nil) async { 75 + guard !archiveLoaded else { return } 76 + archiveLoaded = true 77 + do { 78 + let response = try await client.getStoryArchive(actor: did, auth: auth) 79 + archivedStories = response.stories 80 + archiveCursor = response.cursor 81 + hasMoreArchive = response.cursor != nil 82 + } catch {} 83 + } 84 + 85 + func loadMoreArchive(did: String, auth: AuthContext? = nil) async { 86 + guard !isLoading, hasMoreArchive, let cursor = archiveCursor else { return } 87 + isLoading = true 88 + do { 89 + let response = try await client.getStoryArchive(actor: did, cursor: cursor, auth: auth) 90 + archivedStories.append(contentsOf: response.stories) 91 + archiveCursor = response.cursor 92 + hasMoreArchive = response.cursor != nil 93 + } catch {} 94 + isLoading = false 95 + } 96 + 97 + func loadFavorites(did: String, auth: AuthContext? = nil) async { 98 + guard !favoritesLoaded else { return } 99 + favoritesLoaded = true 100 + do { 101 + let response = try await client.getActorFavorites(actor: did, auth: auth) 102 + favoriteGalleries = response.items ?? [] 103 + favoritesCursor = response.cursor 104 + hasMoreFavorites = response.cursor != nil 105 + } catch {} 106 + } 107 + 108 + func loadMoreFavorites(did: String, auth: AuthContext? = nil) async { 109 + guard !isLoading, hasMoreFavorites, let cursor = favoritesCursor else { return } 110 + isLoading = true 111 + do { 112 + let response = try await client.getActorFavorites(actor: did, cursor: cursor, auth: auth) 113 + favoriteGalleries.append(contentsOf: response.items ?? []) 114 + favoritesCursor = response.cursor 115 + hasMoreFavorites = response.cursor != nil 116 + } catch {} 63 117 isLoading = false 64 118 } 65 119
+288 -64
Grain/Views/Profile/ProfileView.swift
··· 2 2 import SwiftUI 3 3 4 4 enum ProfileViewMode: String, CaseIterable { 5 - case grid, list 5 + case grid, favorites, stories 6 6 } 7 7 8 8 struct ProfileView: View { 9 9 @Namespace private var viewModeNS 10 + @Namespace private var galleryZoomNS 10 11 @Environment(AuthManager.self) private var auth 11 12 @Environment(ViewedStoryStorage.self) private var viewedStories 12 13 @Environment(LabelDefinitionsCache.self) private var labelDefsCache ··· 23 24 @State private var cardStoryAuthor: GrainStoryAuthor? 24 25 @State private var avatarPressed = false 25 26 let client: XRPCClient 27 + @State private var selectedArchivedStory: GrainStory? 26 28 let actor: String 27 29 var isRoot = false 28 30 ··· 201 203 .padding(.horizontal) 202 204 } 203 205 204 - // Galleries 205 - if viewModel.galleries.isEmpty, !viewModel.isLoading { 206 - Text("No galleries yet") 207 - .font(.subheadline) 208 - .foregroundStyle(.tertiary) 209 - .frame(maxWidth: .infinity) 210 - .padding(.top, 60) 211 - } else { 212 - LazyVGrid(columns: [ 213 - GridItem(.flexible(), spacing: 2), 214 - GridItem(.flexible(), spacing: 2), 215 - GridItem(.flexible(), spacing: 2), 216 - ], spacing: 2) { 217 - ForEach(viewModel.galleries) { gallery in 218 - Button { 219 - selectedGalleryUri = gallery.uri 220 - } label: { 221 - Color.clear 222 - .aspectRatio(3.0 / 4.0, contentMode: .fit) 223 - .overlay { 224 - if let photo = gallery.items?.first { 225 - LazyImage(url: URL(string: photo.thumb)) { state in 226 - if let image = state.image { 227 - image 228 - .resizable() 229 - .scaledToFill() 230 - } else { 231 - Rectangle().fill(.quaternary) 232 - } 233 - } 234 - } 235 - } 236 - .clipped() 237 - .overlay { 238 - let lr = resolveLabels(gallery.labels, definitions: labelDefsCache.definitions) 239 - if lr.action >= .warnMedia { 240 - Rectangle().fill(Color(.secondarySystemBackground)) 241 - HStack(spacing: 4) { 242 - Image(systemName: "info.circle.fill") 243 - .font(.caption2) 244 - Text(lr.name) 245 - .font(.system(size: 9)) 246 - } 247 - .foregroundStyle(.secondary) 248 - } 249 - } 250 - .overlay(alignment: .topTrailing) { 251 - if (gallery.items?.count ?? 0) > 1 { 252 - Image(systemName: "square.on.square.fill") 253 - .font(.system(size: 14)) 254 - .rotationEffect(.degrees(180)) 255 - .foregroundStyle(.white) 256 - .shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1) 257 - .padding(6) 258 - } 259 - } 260 - } 261 - .buttonStyle(.plain) 262 - .onAppear { 263 - if gallery.id == viewModel.galleries.last?.id { 264 - Task { await viewModel.loadMoreGalleries(did: did, auth: auth.authContext()) } 265 - } 266 - } 206 + // Tabs + grid 207 + VStack(spacing: 0) { 208 + if did == auth.userDID { 209 + HStack(spacing: 0) { 210 + tabButton(icon: "square.grid.3x3", mode: .grid) 211 + tabButton(icon: "heart", mode: .favorites) 212 + tabButton(icon: "clock", mode: .stories) 267 213 } 268 214 } 215 + 216 + if viewMode == .grid { 217 + galleriesGrid 218 + } 219 + 220 + if viewMode == .favorites { 221 + favoritesGrid 222 + } 223 + 224 + if viewMode == .stories { 225 + storyArchiveGrid 226 + } 269 227 } 228 + .highPriorityGesture( 229 + did == auth.userDID ? 230 + DragGesture(minimumDistance: 30, coordinateSpace: .local) 231 + .onEnded { value in 232 + let h = value.translation.width 233 + let v = value.translation.height 234 + guard abs(h) > abs(v) else { return } 235 + let modes: [ProfileViewMode] = [.grid, .favorites, .stories] 236 + guard let currentIdx = modes.firstIndex(of: viewMode) else { return } 237 + if h < 0, currentIdx < modes.count - 1 { 238 + let next = modes[currentIdx + 1] 239 + withAnimation(.easeInOut(duration: 0.2)) { viewMode = next } 240 + if next == .stories { 241 + Task { await viewModel.loadStoryArchive(did: did, auth: auth.authContext()) } 242 + } else if next == .favorites { 243 + Task { await viewModel.loadFavorites(did: did, auth: auth.authContext()) } 244 + } 245 + } else if h > 0, currentIdx > 0 { 246 + withAnimation(.easeInOut(duration: 0.2)) { viewMode = modes[currentIdx - 1] } 247 + } 248 + } 249 + : nil 250 + ) 270 251 } 271 252 } else if viewModel.error != nil { 272 253 VStack(spacing: 16) { ··· 309 290 } 310 291 .navigationDestination(item: $selectedGalleryUri) { uri in 311 292 GalleryDetailView(client: client, galleryUri: uri, deletedGalleryUri: $deletedGalleryUri) 293 + .navigationTransition(.zoom(sourceID: uri, in: galleryZoomNS)) 312 294 } 313 295 .navigationDestination(item: $selectedProfileDid) { did in 314 296 ProfileView(client: client, did: did) ··· 346 328 ) 347 329 .environment(auth) 348 330 } 331 + .fullScreenCover(item: $selectedArchivedStory) { story in 332 + if let profile = viewModel.profile, 333 + let storyIndex = viewModel.archivedStories.firstIndex(where: { $0.id == story.id }) 334 + { 335 + StoryViewer( 336 + authors: [GrainStoryAuthor( 337 + profile: GrainProfile(cid: "", did: did, handle: profile.handle, displayName: profile.displayName, avatar: profile.avatar), 338 + storyCount: 1, 339 + latestAt: story.createdAt 340 + )], 341 + initialStories: [story], 342 + client: client, 343 + onProfileTap: { did in 344 + selectedArchivedStory = nil 345 + selectedProfileDid = did 346 + }, 347 + onDismiss: { selectedArchivedStory = nil } 348 + ) 349 + .environment(auth) 350 + } 351 + } 349 352 .fullScreenCover(isPresented: $showStoryCreate) { 350 353 StoryCreateView(client: client, onCreated: { 351 354 Task { await viewModel.load(did: did) } ··· 423 426 } 424 427 .frame(maxWidth: .infinity, alignment: .leading) 425 428 .padding(.horizontal) 429 + } 430 + 431 + private func tabButton(icon: String, mode: ProfileViewMode) -> some View { 432 + let isActive = viewMode == mode 433 + let symbolName = isActive ? icon + ".fill" : icon 434 + return Button { 435 + withAnimation(.easeInOut(duration: 0.2)) { viewMode = mode } 436 + if mode == .stories { 437 + Task { await viewModel.loadStoryArchive(did: did, auth: auth.authContext()) } 438 + } else if mode == .favorites { 439 + Task { await viewModel.loadFavorites(did: did, auth: auth.authContext()) } 440 + } 441 + } label: { 442 + VStack(spacing: 4) { 443 + Image(systemName: symbolName) 444 + .font(.system(size: 22)) 445 + .foregroundStyle(isActive ? .primary : .secondary) 446 + Rectangle() 447 + .fill(viewMode == mode ? Color("AccentColor") : .clear) 448 + .frame(width: 32, height: 2.5) 449 + } 450 + .frame(maxWidth: .infinity) 451 + .padding(.vertical, 8) 452 + } 453 + .buttonStyle(.plain) 454 + } 455 + 456 + @ViewBuilder 457 + private var galleriesGrid: some View { 458 + if viewModel.galleries.isEmpty, !viewModel.isLoading { 459 + Text("No galleries yet") 460 + .font(.subheadline) 461 + .foregroundStyle(.tertiary) 462 + .frame(maxWidth: .infinity) 463 + .padding(.top, 60) 464 + } else { 465 + LazyVGrid(columns: [ 466 + GridItem(.flexible(), spacing: 2), 467 + GridItem(.flexible(), spacing: 2), 468 + GridItem(.flexible(), spacing: 2), 469 + ], spacing: 2) { 470 + ForEach(viewModel.galleries) { gallery in 471 + Button { 472 + selectedGalleryUri = nil 473 + DispatchQueue.main.async { 474 + selectedGalleryUri = gallery.uri 475 + } 476 + } label: { 477 + Color.clear 478 + .aspectRatio(3.0 / 4.0, contentMode: .fit) 479 + .overlay { 480 + if let photo = gallery.items?.first { 481 + LazyImage(url: URL(string: photo.thumb)) { state in 482 + if let image = state.image { 483 + image 484 + .resizable() 485 + .scaledToFill() 486 + } else { 487 + Rectangle().fill(.quaternary) 488 + } 489 + } 490 + } 491 + } 492 + .clipped() 493 + .contentShape(Rectangle()) 494 + .overlay { 495 + let lr = resolveLabels(gallery.labels, definitions: labelDefsCache.definitions) 496 + if lr.action >= .warnMedia { 497 + Rectangle().fill(Color(.secondarySystemBackground)) 498 + HStack(spacing: 4) { 499 + Image(systemName: "info.circle.fill") 500 + .font(.caption2) 501 + Text(lr.name) 502 + .font(.system(size: 9)) 503 + } 504 + .foregroundStyle(.secondary) 505 + } 506 + } 507 + .overlay(alignment: .topTrailing) { 508 + if (gallery.items?.count ?? 0) > 1 { 509 + Image(systemName: "square.on.square.fill") 510 + .font(.system(size: 14)) 511 + .rotationEffect(.degrees(180)) 512 + .foregroundStyle(.white) 513 + .shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1) 514 + .padding(6) 515 + } 516 + } 517 + } 518 + .buttonStyle(.plain) 519 + .matchedTransitionSource(id: gallery.uri, in: galleryZoomNS) 520 + .onAppear { 521 + if gallery.id == viewModel.galleries.last?.id { 522 + Task { await viewModel.loadMoreGalleries(did: did, auth: auth.authContext()) } 523 + } 524 + } 525 + } 526 + } 527 + } 528 + } 529 + 530 + @ViewBuilder 531 + private var storyArchiveGrid: some View { 532 + if viewModel.archivedStories.isEmpty, !viewModel.isLoading { 533 + Text("No stories yet") 534 + .font(.subheadline) 535 + .foregroundStyle(.tertiary) 536 + .frame(maxWidth: .infinity) 537 + .padding(.top, 60) 538 + } else { 539 + LazyVGrid(columns: [ 540 + GridItem(.flexible(), spacing: 2), 541 + GridItem(.flexible(), spacing: 2), 542 + GridItem(.flexible(), spacing: 2), 543 + ], spacing: 2) { 544 + ForEach(viewModel.archivedStories) { story in 545 + Button { 546 + if let index = viewModel.archivedStories.firstIndex(where: { $0.id == story.id }) { 547 + selectedArchivedStory = viewModel.archivedStories[index] 548 + } 549 + } label: { 550 + Color.clear 551 + .aspectRatio(3.0 / 4.0, contentMode: .fit) 552 + .overlay { 553 + LazyImage(url: URL(string: story.thumb)) { state in 554 + if let image = state.image { 555 + image 556 + .resizable() 557 + .scaledToFill() 558 + } else { 559 + Rectangle().fill(.quaternary) 560 + } 561 + } 562 + } 563 + .clipped() 564 + .overlay(alignment: .bottomLeading) { 565 + Text(storyDateLabel(story.createdAt)) 566 + .font(.system(size: 11, weight: .medium)) 567 + .foregroundStyle(.white) 568 + .shadow(color: .black.opacity(0.6), radius: 2, y: 1) 569 + .padding(6) 570 + } 571 + } 572 + .buttonStyle(.plain) 573 + .onAppear { 574 + if story.id == viewModel.archivedStories.last?.id { 575 + Task { await viewModel.loadMoreArchive(did: did, auth: auth.authContext()) } 576 + } 577 + } 578 + } 579 + } 580 + } 581 + } 582 + 583 + @ViewBuilder 584 + private var favoritesGrid: some View { 585 + if viewModel.favoriteGalleries.isEmpty, !viewModel.isLoading { 586 + Text("No favorites yet") 587 + .font(.subheadline) 588 + .foregroundStyle(.tertiary) 589 + .frame(maxWidth: .infinity) 590 + .padding(.top, 60) 591 + } else { 592 + LazyVGrid(columns: [ 593 + GridItem(.flexible(), spacing: 2), 594 + GridItem(.flexible(), spacing: 2), 595 + GridItem(.flexible(), spacing: 2), 596 + ], spacing: 2) { 597 + ForEach(viewModel.favoriteGalleries) { gallery in 598 + Button { 599 + selectedGalleryUri = nil 600 + DispatchQueue.main.async { 601 + selectedGalleryUri = gallery.uri 602 + } 603 + } label: { 604 + Color.clear 605 + .aspectRatio(3.0 / 4.0, contentMode: .fit) 606 + .overlay { 607 + if let photo = gallery.items?.first { 608 + LazyImage(url: URL(string: photo.thumb)) { state in 609 + if let image = state.image { 610 + image 611 + .resizable() 612 + .scaledToFill() 613 + } else { 614 + Rectangle().fill(.quaternary) 615 + } 616 + } 617 + } 618 + } 619 + .clipped() 620 + .overlay(alignment: .topTrailing) { 621 + if (gallery.items?.count ?? 0) > 1 { 622 + Image(systemName: "square.on.square.fill") 623 + .font(.system(size: 14)) 624 + .rotationEffect(.degrees(180)) 625 + .foregroundStyle(.white) 626 + .shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1) 627 + .padding(6) 628 + } 629 + } 630 + } 631 + .buttonStyle(.plain) 632 + .matchedTransitionSource(id: gallery.uri, in: galleryZoomNS) 633 + .onAppear { 634 + if gallery.id == viewModel.favoriteGalleries.last?.id { 635 + Task { await viewModel.loadMoreFavorites(did: did, auth: auth.authContext()) } 636 + } 637 + } 638 + } 639 + } 640 + } 641 + } 642 + 643 + private func storyDateLabel(_ iso: String) -> String { 644 + let formatter = ISO8601DateFormatter() 645 + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 646 + guard let date = formatter.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) else { return "" } 647 + let display = DateFormatter() 648 + display.dateFormat = "MMM d" 649 + return display.string(from: date) 426 650 } 427 651 428 652 @ViewBuilder
+8 -1
Grain/Views/Stories/StoryViewer.swift
··· 87 87 @State private var imagePrefetcher = ImagePrefetcher() 88 88 @State private var isDragging = false 89 89 90 - init(authors: [GrainStoryAuthor], startAuthorDid: String? = nil, client: XRPCClient, onProfileTap: ((String) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { 90 + init(authors: [GrainStoryAuthor], startAuthorDid: String? = nil, initialStories: [GrainStory]? = nil, startStoryIndex: Int? = nil, client: XRPCClient, onProfileTap: ((String) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { 91 91 self.authors = authors 92 92 self.client = client 93 93 self.onProfileTap = onProfileTap 94 94 self.onDismiss = onDismiss 95 95 let resolvedIndex = startAuthorDid.flatMap { did in authors.firstIndex { $0.profile.did == did } } ?? 0 96 96 _currentAuthorIndex = State(initialValue: resolvedIndex) 97 + if let initialStories { 98 + let did = authors[resolvedIndex].profile.did 99 + _prefetchedStories = State(initialValue: [did: initialStories]) 100 + if let startStoryIndex { 101 + _currentStoryIndex = State(initialValue: startStoryIndex) 102 + } 103 + } 97 104 } 98 105 99 106 private var currentStory: GrainStory? {