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: add stories feature with viewer, strip, creation, and profile indicator

Stories matching the web client: story strip on feed with gradient rings,
full-screen viewer with auto-advance/tap navigation/swipe between authors,
story ring on profile avatars, and story creation with photo/location.

Also extracts shared ImageProcessing and LocationServices utilities,
adds custom fullscreen cover animation, fixes DPoP warning, and
neutralizes avatar fallback colors for dark backgrounds.

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

+1031 -282
+1 -1
Grain/API/DPoP.swift
··· 4 4 5 5 /// DPoP (Demonstration of Proof-of-Possession) proof generator using ES256. 6 6 final class DPoP: Sendable { 7 - nonisolated(unsafe) private let privateKey: P256.Signing.PrivateKey 7 + private let privateKey: P256.Signing.PrivateKey 8 8 let publicJWK: [String: String] 9 9 let thumbprint: String 10 10
+68
Grain/Utilities/ImageProcessing.swift
··· 1 + import ImageIO 2 + import UIKit 3 + 4 + enum ImageProcessing { 5 + /// Resize image to fit within maxDimension and binary-search JPEG quality to stay under maxBytes. 6 + static func resizeImage(_ image: UIImage, maxDimension: CGFloat, maxBytes: Int) -> (Data, CGSize) { 7 + let pixelWidth = image.size.width * image.scale 8 + let pixelHeight = image.size.height * image.scale 9 + let scaleFactor = min(maxDimension / pixelWidth, maxDimension / pixelHeight, 1) 10 + var newSize = CGSize(width: round(pixelWidth * scaleFactor), height: round(pixelHeight * scaleFactor)) 11 + 12 + let format = UIGraphicsImageRendererFormat() 13 + format.scale = 1 14 + var renderer = UIGraphicsImageRenderer(size: newSize, format: format) 15 + var scaled = renderer.image { _ in 16 + image.draw(in: CGRect(origin: .zero, size: newSize)) 17 + } 18 + 19 + var best = scaled.jpegData(compressionQuality: 0.01) ?? Data() 20 + var lo: CGFloat = 0 21 + var hi: CGFloat = 1 22 + 23 + for _ in 0..<10 { 24 + let mid = (lo + hi) / 2 25 + guard let data = scaled.jpegData(compressionQuality: mid) else { break } 26 + if data.count <= maxBytes { 27 + best = data 28 + lo = mid 29 + } else { 30 + hi = mid 31 + } 32 + } 33 + 34 + if best.count > maxBytes { 35 + let downScale = sqrt(Double(maxBytes) / Double(best.count)) 36 + newSize = CGSize(width: round(newSize.width * downScale), height: round(newSize.height * downScale)) 37 + let fmt = UIGraphicsImageRendererFormat() 38 + fmt.scale = 1 39 + renderer = UIGraphicsImageRenderer(size: newSize, format: fmt) 40 + scaled = renderer.image { _ in 41 + image.draw(in: CGRect(origin: .zero, size: newSize)) 42 + } 43 + best = scaled.jpegData(compressionQuality: 0.8) ?? Data() 44 + } 45 + 46 + return (best, newSize) 47 + } 48 + 49 + /// Extract GPS coordinates from image data. Returns (latitude, longitude) or nil. 50 + static func extractGPS(from data: Data) -> (latitude: Double, longitude: Double)? { 51 + guard let source = CGImageSourceCreateWithData(data as CFData, nil), 52 + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any], 53 + let gpsDict = properties[kCGImagePropertyGPSDictionary as String] as? [String: Any] else { 54 + return nil 55 + } 56 + 57 + guard let latitude = gpsDict[kCGImagePropertyGPSLatitude as String] as? Double, 58 + let latRef = gpsDict[kCGImagePropertyGPSLatitudeRef as String] as? String, 59 + let longitude = gpsDict[kCGImagePropertyGPSLongitude as String] as? Double, 60 + let lonRef = gpsDict[kCGImagePropertyGPSLongitudeRef as String] as? String else { 61 + return nil 62 + } 63 + 64 + let lat = latRef == "S" ? -latitude : latitude 65 + let lon = lonRef == "W" ? -longitude : longitude 66 + return (lat, lon) 67 + } 68 + }
+121
Grain/Utilities/LocationServices.swift
··· 1 + import Foundation 2 + import SwiftyH3 3 + 4 + struct NominatimResult { 5 + let placeId: Int 6 + let latitude: Double 7 + let longitude: Double 8 + let name: String 9 + let context: String? 10 + let address: [String: AnyCodable]? 11 + 12 + init?(from json: [String: Any]) { 13 + guard let placeId = json["place_id"] as? Int else { return nil } 14 + self.placeId = placeId 15 + 16 + if let lat = json["lat"] as? String, let lon = json["lon"] as? String, 17 + let latD = Double(lat), let lonD = Double(lon) { 18 + self.latitude = latD 19 + self.longitude = lonD 20 + } else if let lat = json["lat"] as? Double, let lon = json["lon"] as? Double { 21 + self.latitude = lat 22 + self.longitude = lon 23 + } else { 24 + return nil 25 + } 26 + 27 + let addr = json["address"] as? [String: Any] 28 + let city = addr?["city"] as? String ?? addr?["town"] as? String ?? addr?["village"] as? String 29 + 30 + if let placeName = json["name"] as? String, !placeName.isEmpty { 31 + self.name = placeName 32 + } else { 33 + var parts: [String] = [] 34 + if let city { parts.append(city) } 35 + if let state = addr?["state"] as? String { parts.append(state) } 36 + if let country = addr?["country"] as? String { parts.append(country) } 37 + self.name = parts.isEmpty 38 + ? (json["display_name"] as? String ?? "Unknown").components(separatedBy: ",").first ?? "Unknown" 39 + : parts.joined(separator: ", ") 40 + } 41 + 42 + var contextParts: [String] = [] 43 + if let city { contextParts.append(city) } 44 + if let state = addr?["state"] as? String { contextParts.append(state) } 45 + if let country = addr?["country"] as? String { contextParts.append(country) } 46 + self.context = contextParts.isEmpty ? nil : contextParts.joined(separator: ", ") 47 + 48 + if let countryCode = (addr?["country_code"] as? String)?.uppercased() { 49 + var a: [String: AnyCodable] = ["country": AnyCodable(countryCode)] 50 + if let city { a["locality"] = AnyCodable(city) } 51 + if let state = addr?["state"] as? String { a["region"] = AnyCodable(state) } 52 + if let road = addr?["road"] as? String { 53 + if let houseNumber = addr?["house_number"] as? String { 54 + a["street"] = AnyCodable("\(houseNumber) \(road)") 55 + } else { 56 + a["street"] = AnyCodable(road) 57 + } 58 + } 59 + if let postcode = addr?["postcode"] as? String { a["postalCode"] = AnyCodable(postcode) } 60 + self.address = a 61 + } else { 62 + self.address = nil 63 + } 64 + } 65 + } 66 + 67 + enum LocationServices { 68 + /// Convert lat/lon to H3 index string at resolution 10. 69 + static func latLonToH3(latitude: Double, longitude: Double) -> String { 70 + let latLng = H3LatLng(latitudeDegs: latitude, longitudeDegs: longitude) 71 + guard let cell = try? latLng.cell(at: .res10) else { return "" } 72 + return cell.description 73 + } 74 + 75 + /// Reverse geocode coordinates via Nominatim. 76 + static func reverseGeocode(latitude: Double, longitude: Double) async -> NominatimResult? { 77 + var components = URLComponents(string: "https://nominatim.openstreetmap.org/reverse")! 78 + components.queryItems = [ 79 + URLQueryItem(name: "format", value: "json"), 80 + URLQueryItem(name: "lat", value: String(latitude)), 81 + URLQueryItem(name: "lon", value: String(longitude)), 82 + URLQueryItem(name: "addressdetails", value: "1"), 83 + ] 84 + guard let url = components.url else { return nil } 85 + 86 + var request = URLRequest(url: url) 87 + request.setValue("grain-app/1.0", forHTTPHeaderField: "User-Agent") 88 + 89 + guard let (data, _) = try? await URLSession.shared.data(for: request), 90 + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { 91 + return nil 92 + } 93 + 94 + return NominatimResult(from: json) 95 + } 96 + 97 + /// Search for locations via Nominatim. 98 + static func searchLocation(query: String) async -> [NominatimResult] { 99 + let trimmed = query.trimmingCharacters(in: .whitespaces) 100 + guard trimmed.count >= 2 else { return [] } 101 + 102 + var components = URLComponents(string: "https://nominatim.openstreetmap.org/search")! 103 + components.queryItems = [ 104 + URLQueryItem(name: "format", value: "json"), 105 + URLQueryItem(name: "q", value: trimmed), 106 + URLQueryItem(name: "limit", value: "5"), 107 + URLQueryItem(name: "addressdetails", value: "1"), 108 + ] 109 + guard let url = components.url else { return [] } 110 + 111 + var request = URLRequest(url: url) 112 + request.setValue("grain-app/1.0", forHTTPHeaderField: "User-Agent") 113 + 114 + guard let (data, _) = try? await URLSession.shared.data(for: request), 115 + let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { 116 + return [] 117 + } 118 + 119 + return json.compactMap { NominatimResult(from: $0) } 120 + } 121 + }
+25
Grain/ViewModels/StoryStripViewModel.swift
··· 1 + import Foundation 2 + 3 + @Observable 4 + @MainActor 5 + final class StoryStripViewModel { 6 + var authors: [GrainStoryAuthor] = [] 7 + var isLoading = false 8 + 9 + private let client: XRPCClient 10 + 11 + init(client: XRPCClient) { 12 + self.client = client 13 + } 14 + 15 + func load(auth: AuthContext? = nil) async { 16 + isLoading = true 17 + do { 18 + let response = try await client.getStoryAuthors(auth: auth) 19 + authors = response.authors 20 + } catch { 21 + // Silently fail — strip just won't show 22 + } 23 + isLoading = false 24 + } 25 + }
+2 -2
Grain/Views/Components/AvatarView.swift
··· 25 25 26 26 private var fallback: some View { 27 27 ZStack { 28 - Circle().fill(.quaternary) 28 + Circle().fill(Color.gray.opacity(0.3)) 29 29 Image(systemName: "person.fill") 30 30 .font(.system(size: size * 0.45)) 31 - .foregroundStyle(.tertiary) 31 + .foregroundStyle(Color.gray.opacity(0.6)) 32 32 } 33 33 } 34 34 }
+59
Grain/Views/Components/CustomFullScreenCover.swift
··· 1 + import SwiftUI 2 + 3 + extension View { 4 + func customFullScreenCover<Content: View>( 5 + isPresented: Binding<Bool>, 6 + transition: AnyTransition = .scale(scale: 0.9).combined(with: .opacity), 7 + animation: Animation = .easeInOut(duration: 0.25), 8 + @ViewBuilder content: @escaping () -> Content 9 + ) -> some View { 10 + modifier( 11 + CustomFullScreenCoverModifier( 12 + isPresented: isPresented, 13 + transition: transition, 14 + animation: animation, 15 + presentedView: content 16 + ) 17 + ) 18 + } 19 + } 20 + 21 + private struct CustomFullScreenCoverModifier<PresentedView: View>: ViewModifier { 22 + @Binding var isPresented: Bool 23 + let transition: AnyTransition 24 + let animation: Animation 25 + @ViewBuilder let presentedView: () -> PresentedView 26 + 27 + @State private var isPresentedInternal = false 28 + @State private var isShowContent = false 29 + 30 + func body(content: Content) -> some View { 31 + content 32 + .fullScreenCover(isPresented: $isPresentedInternal) { 33 + ZStack { 34 + if isShowContent { 35 + presentedView() 36 + .transition(transition) 37 + } 38 + } 39 + .onAppear { 40 + withAnimation(animation) { 41 + isShowContent = true 42 + } 43 + } 44 + .presentationBackground(.clear) 45 + } 46 + .onChange(of: isPresented) { _, newValue in 47 + if newValue { 48 + isShowContent = false 49 + isPresentedInternal = true 50 + } else { 51 + withAnimation(animation) { 52 + isShowContent = false 53 + } completion: { 54 + isPresentedInternal = false 55 + } 56 + } 57 + } 58 + } 59 + }
+6 -219
Grain/Views/Create/CreateGalleryView.swift
··· 2 2 import os 3 3 import PhotosUI 4 4 import SwiftUI 5 - import SwiftyH3 6 5 7 6 private let logger = Logger(subsystem: "social.grain.grain", category: "Create") 8 7 ··· 150 149 } 151 150 } 152 151 153 - /// Auto-detect location from first photo with GPS when photos are selected. 154 152 private func detectLocation() async { 155 153 resolvedLocation = nil 156 154 locationQuery = "" ··· 158 156 159 157 for item in selectedPhotos { 160 158 guard let data = try? await item.loadTransferable(type: Data.self), 161 - let gps = extractGPS(from: data) else { continue } 159 + let gps = ImageProcessing.extractGPS(from: data) else { continue } 162 160 163 - if let result = await reverseGeocode(latitude: gps.latitude, longitude: gps.longitude) { 161 + if let result = await LocationServices.reverseGeocode(latitude: gps.latitude, longitude: gps.longitude) { 164 162 selectLocation(result) 165 163 } 166 164 break 167 165 } 168 166 } 169 167 170 - /// Forward geocode search via Nominatim. 171 168 private func searchLocation(query: String) async { 172 - guard query.trimmingCharacters(in: .whitespaces).count >= 2 else { 173 - locationSuggestions = [] 174 - return 175 - } 176 169 isSearchingLocation = true 177 170 defer { isSearchingLocation = false } 178 - 179 - var components = URLComponents(string: "https://nominatim.openstreetmap.org/search")! 180 - components.queryItems = [ 181 - URLQueryItem(name: "format", value: "json"), 182 - URLQueryItem(name: "q", value: query.trimmingCharacters(in: .whitespaces)), 183 - URLQueryItem(name: "limit", value: "5"), 184 - URLQueryItem(name: "addressdetails", value: "1"), 185 - ] 186 - guard let url = components.url else { return } 187 - 188 - var request = URLRequest(url: url) 189 - request.setValue("grain-app/1.0", forHTTPHeaderField: "User-Agent") 190 - 191 - guard let (data, _) = try? await URLSession.shared.data(for: request), 192 - let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { 193 - locationSuggestions = [] 194 - return 195 - } 196 - 197 - locationSuggestions = json.compactMap { NominatimResult(from: $0) } 171 + locationSuggestions = await LocationServices.searchLocation(query: query) 198 172 } 199 173 200 - /// Select a location from search results. 201 174 private func selectLocation(_ result: NominatimResult) { 202 - let h3 = latLonToH3(latitude: result.latitude, longitude: result.longitude) 175 + let h3 = LocationServices.latLonToH3(latitude: result.latitude, longitude: result.longitude) 203 176 resolvedLocation = (h3: h3, name: result.name, address: result.address) 204 177 locationQuery = "" 205 178 locationSuggestions = [] ··· 224 197 let original = UIImage(data: data) else { continue } 225 198 226 199 let exif = extractExif(from: data) 227 - let (resized, size) = resizeImage(original, maxDimension: 2000, maxBytes: 900_000) 200 + let (resized, size) = ImageProcessing.resizeImage(original, maxDimension: 2000, maxBytes: 900_000) 228 201 logger.warning("Uploading \(resized.count) bytes, \(Int(size.width))x\(Int(size.height))") 229 202 let response = try await client.uploadBlob(data: resized, mimeType: "image/jpeg", auth: authContext) 230 203 processed.append(ProcessedPhoto( ··· 323 296 isUploading = false 324 297 } 325 298 326 - // MARK: - Image Processing 327 - 328 - /// Resize image to fit within maxDimension and binary-search JPEG quality to stay under maxBytes. 329 - private func resizeImage(_ image: UIImage, maxDimension: CGFloat, maxBytes: Int) -> (Data, CGSize) { 330 - // Use pixel dimensions, not points 331 - let pixelWidth = image.size.width * image.scale 332 - let pixelHeight = image.size.height * image.scale 333 - let scaleFactor = min(maxDimension / pixelWidth, maxDimension / pixelHeight, 1) 334 - var newSize = CGSize(width: round(pixelWidth * scaleFactor), height: round(pixelHeight * scaleFactor)) 335 - 336 - // Render at 1x scale so size = pixels 337 - let format = UIGraphicsImageRendererFormat() 338 - format.scale = 1 339 - var renderer = UIGraphicsImageRenderer(size: newSize, format: format) 340 - var scaled = renderer.image { _ in 341 - image.draw(in: CGRect(origin: .zero, size: newSize)) 342 - } 299 + // MARK: - EXIF Extraction (gallery-specific, not shared) 343 300 344 - // Binary search for quality that fits under maxBytes 345 - var best = scaled.jpegData(compressionQuality: 0.01) ?? Data() 346 - var lo: CGFloat = 0 347 - var hi: CGFloat = 1 348 - 349 - for _ in 0..<10 { 350 - let mid = (lo + hi) / 2 351 - guard let data = scaled.jpegData(compressionQuality: mid) else { break } 352 - if data.count <= maxBytes { 353 - best = data 354 - lo = mid 355 - } else { 356 - hi = mid 357 - } 358 - } 359 - 360 - // If even lowest quality is too large, scale down further 361 - if best.count > maxBytes { 362 - let downScale = sqrt(Double(maxBytes) / Double(best.count)) 363 - newSize = CGSize(width: round(newSize.width * downScale), height: round(newSize.height * downScale)) 364 - let fmt = UIGraphicsImageRendererFormat() 365 - fmt.scale = 1 366 - renderer = UIGraphicsImageRenderer(size: newSize, format: fmt) 367 - scaled = renderer.image { _ in 368 - image.draw(in: CGRect(origin: .zero, size: newSize)) 369 - } 370 - best = scaled.jpegData(compressionQuality: 0.8) ?? Data() 371 - } 372 - 373 - return (best, newSize) 374 - } 375 - 376 - /// Extract EXIF metadata from image data using ImageIO. Returns nil if no useful EXIF found. 377 - /// Numeric values are scaled by 1_000_000 to match the web app's format. 378 301 private func extractExif(from data: Data) -> [String: AnyCodable]? { 379 302 let scale = 1_000_000 380 303 ··· 389 312 let exifAux = properties[kCGImagePropertyExifAuxDictionary as String] as? [String: Any] 390 313 var result: [String: AnyCodable] = [:] 391 314 392 - // Make & Model (from TIFF) 393 315 if let make = tiffDict?[kCGImagePropertyTIFFMake as String] as? String { 394 316 result["make"] = AnyCodable(make.trimmingCharacters(in: .whitespaces)) 395 317 } 396 318 if let model = tiffDict?[kCGImagePropertyTIFFModel as String] as? String { 397 319 result["model"] = AnyCodable(model.trimmingCharacters(in: .whitespaces)) 398 320 } 399 - 400 - // Lens — check ExifAux first (where iOS typically puts it), then EXIF dict 401 321 if let lensMake = exifAux?["LensMake"] as? String ?? exifDict?["LensMake"] as? String ?? tiffDict?[kCGImagePropertyTIFFMake as String] as? String { 402 322 result["lensMake"] = AnyCodable(lensMake.trimmingCharacters(in: .whitespaces)) 403 323 } 404 324 if let lensModel = exifAux?["LensModel"] as? String ?? exifDict?[kCGImagePropertyExifLensModel as String] as? String { 405 325 result["lensModel"] = AnyCodable(lensModel.trimmingCharacters(in: .whitespaces)) 406 326 } 407 - 408 - // Exposure Time (seconds -> scaled int) 409 327 if let exposureTime = exifDict?[kCGImagePropertyExifExposureTime as String] as? Double { 410 328 result["exposureTime"] = AnyCodable(Int(exposureTime * Double(scale))) 411 329 } 412 - 413 - // F-Number 414 330 if let fNumber = exifDict?[kCGImagePropertyExifFNumber as String] as? Double { 415 331 result["fNumber"] = AnyCodable(Int(fNumber * Double(scale))) 416 332 } 417 - 418 - // ISO — can be [Int] or NSArray of NSNumber 419 333 if let isoRaw = exifDict?[kCGImagePropertyExifISOSpeedRatings as String] as? [Any], 420 334 let iso = (isoRaw.first as? NSNumber)?.intValue { 421 335 result["iSO"] = AnyCodable(iso * scale) 422 336 } 423 - 424 - // Focal Length in 35mm 425 337 if let focal35 = exifDict?[kCGImagePropertyExifFocalLenIn35mmFilm as String] as? Int { 426 338 result["focalLengthIn35mmFormat"] = AnyCodable(focal35 * scale) 427 339 } else if let focal35 = exifDict?[kCGImagePropertyExifFocalLenIn35mmFilm as String] as? Double { 428 340 result["focalLengthIn35mmFormat"] = AnyCodable(Int(focal35) * scale) 429 341 } 430 - 431 - // Flash 432 342 if let flash = exifDict?[kCGImagePropertyExifFlash as String] as? Int { 433 343 let flashStr: String 434 344 switch flash { ··· 443 353 } 444 354 result["flash"] = AnyCodable(flashStr) 445 355 } 446 - 447 - // DateTimeOriginal 448 356 if let dateStr = exifDict?[kCGImagePropertyExifDateTimeOriginal as String] as? String { 449 - // EXIF format: "2024:01:15 14:30:00" -> ISO 8601 450 357 let formatter = DateFormatter() 451 358 formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" 452 359 if let date = formatter.date(from: dateStr) { ··· 455 362 } 456 363 457 364 return result.isEmpty ? nil : result 458 - } 459 - 460 - // MARK: - GPS & Location 461 - 462 - /// Extract GPS coordinates from image data. Returns (latitude, longitude) or nil. 463 - private func extractGPS(from data: Data) -> (latitude: Double, longitude: Double)? { 464 - guard let source = CGImageSourceCreateWithData(data as CFData, nil), 465 - let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any], 466 - let gpsDict = properties[kCGImagePropertyGPSDictionary as String] as? [String: Any] else { 467 - return nil 468 - } 469 - 470 - guard let latitude = gpsDict[kCGImagePropertyGPSLatitude as String] as? Double, 471 - let latRef = gpsDict[kCGImagePropertyGPSLatitudeRef as String] as? String, 472 - let longitude = gpsDict[kCGImagePropertyGPSLongitude as String] as? Double, 473 - let lonRef = gpsDict[kCGImagePropertyGPSLongitudeRef as String] as? String else { 474 - return nil 475 - } 476 - 477 - let lat = latRef == "S" ? -latitude : latitude 478 - let lon = lonRef == "W" ? -longitude : longitude 479 - return (lat, lon) 480 - } 481 - 482 - /// Convert lat/lon to H3 index string at resolution 10. 483 - private func latLonToH3(latitude: Double, longitude: Double) -> String { 484 - let latLng = H3LatLng(latitudeDegs: latitude, longitudeDegs: longitude) 485 - guard let cell = try? latLng.cell(at: .res10) else { return "" } 486 - return cell.description 487 - } 488 - 489 - /// Reverse geocode coordinates via Nominatim. 490 - private func reverseGeocode(latitude: Double, longitude: Double) async -> NominatimResult? { 491 - var components = URLComponents(string: "https://nominatim.openstreetmap.org/reverse")! 492 - components.queryItems = [ 493 - URLQueryItem(name: "format", value: "json"), 494 - URLQueryItem(name: "lat", value: String(latitude)), 495 - URLQueryItem(name: "lon", value: String(longitude)), 496 - URLQueryItem(name: "addressdetails", value: "1"), 497 - ] 498 - guard let url = components.url else { return nil } 499 - 500 - var request = URLRequest(url: url) 501 - request.setValue("grain-app/1.0", forHTTPHeaderField: "User-Agent") 502 - 503 - guard let (data, _) = try? await URLSession.shared.data(for: request), 504 - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { 505 - return nil 506 - } 507 - 508 - return NominatimResult(from: json) 509 - } 510 - } 511 - 512 - // MARK: - Nominatim 513 - 514 - private struct NominatimResult { 515 - let placeId: Int 516 - let latitude: Double 517 - let longitude: Double 518 - let name: String 519 - let context: String? 520 - let address: [String: AnyCodable]? 521 - 522 - init?(from json: [String: Any]) { 523 - guard let placeId = json["place_id"] as? Int else { return nil } 524 - self.placeId = placeId 525 - 526 - // lat/lon can be String or Double from Nominatim 527 - if let lat = json["lat"] as? String, let lon = json["lon"] as? String, 528 - let latD = Double(lat), let lonD = Double(lon) { 529 - self.latitude = latD 530 - self.longitude = lonD 531 - } else if let lat = json["lat"] as? Double, let lon = json["lon"] as? Double { 532 - self.latitude = lat 533 - self.longitude = lon 534 - } else { 535 - return nil 536 - } 537 - 538 - let addr = json["address"] as? [String: Any] 539 - let city = addr?["city"] as? String ?? addr?["town"] as? String ?? addr?["village"] as? String 540 - 541 - // Name: place name or city/state/country 542 - if let placeName = json["name"] as? String, !placeName.isEmpty { 543 - self.name = placeName 544 - } else { 545 - var parts: [String] = [] 546 - if let city { parts.append(city) } 547 - if let state = addr?["state"] as? String { parts.append(state) } 548 - if let country = addr?["country"] as? String { parts.append(country) } 549 - self.name = parts.isEmpty 550 - ? (json["display_name"] as? String ?? "Unknown").components(separatedBy: ",").first ?? "Unknown" 551 - : parts.joined(separator: ", ") 552 - } 553 - 554 - // Context: city, state, country for disambiguation 555 - var contextParts: [String] = [] 556 - if let city { contextParts.append(city) } 557 - if let state = addr?["state"] as? String { contextParts.append(state) } 558 - if let country = addr?["country"] as? String { contextParts.append(country) } 559 - self.context = contextParts.isEmpty ? nil : contextParts.joined(separator: ", ") 560 - 561 - // Structured address 562 - if let countryCode = (addr?["country_code"] as? String)?.uppercased() { 563 - var a: [String: AnyCodable] = ["country": AnyCodable(countryCode)] 564 - if let city { a["locality"] = AnyCodable(city) } 565 - if let state = addr?["state"] as? String { a["region"] = AnyCodable(state) } 566 - if let road = addr?["road"] as? String { 567 - if let houseNumber = addr?["house_number"] as? String { 568 - a["street"] = AnyCodable("\(houseNumber) \(road)") 569 - } else { 570 - a["street"] = AnyCodable(road) 571 - } 572 - } 573 - if let postcode = addr?["postcode"] as? String { a["postalCode"] = AnyCodable(postcode) } 574 - self.address = a 575 - } else { 576 - self.address = nil 577 - } 578 365 } 579 366 }
+58 -3
Grain/Views/Feed/FeedView.swift
··· 5 5 @State private var pinnedFeeds: [PinnedFeed] = PinnedFeed.defaults 6 6 @State private var selectedFeedId: String = "recent" 7 7 @State private var hasLoadedPreferences = false 8 + @State private var storyViewModel: StoryStripViewModel 9 + @State private var showStoryViewer = false 10 + @State private var storyViewerStartIndex = 0 11 + @State private var showStoryCreate = false 8 12 9 13 let client: XRPCClient 10 14 11 15 init(client: XRPCClient) { 12 16 self.client = client 17 + _storyViewModel = State(initialValue: StoryStripViewModel(client: client)) 13 18 } 14 19 15 20 private var selectedFeedLabel: String { ··· 21 26 ZStack { 22 27 ForEach(pinnedFeeds) { feed in 23 28 if feed.id == selectedFeedId { 24 - FeedTabContent(client: client, pinnedFeed: feed, userDID: auth.userDID) 29 + FeedTabContent( 30 + client: client, 31 + pinnedFeed: feed, 32 + userDID: auth.userDID, 33 + storyAuthors: storyViewModel.authors, 34 + userAvatar: auth.userAvatar, 35 + onStoryAuthorTap: { _, index in 36 + storyViewerStartIndex = index 37 + showStoryViewer = true 38 + }, 39 + onStoryCreateTap: { showStoryCreate = true }, 40 + onRefresh: { 41 + await storyViewModel.load(auth: auth.authContext()) 42 + } 43 + ) 25 44 } 26 45 } 27 46 } ··· 57 76 } 58 77 .task { 59 78 await loadPreferences() 79 + await storyViewModel.load(auth: auth.authContext()) 80 + } 81 + .customFullScreenCover(isPresented: $showStoryViewer) { 82 + StoryViewer( 83 + authors: storyViewModel.authors, 84 + startIndex: storyViewerStartIndex, 85 + client: client, 86 + onDismiss: { showStoryViewer = false } 87 + ) 88 + } 89 + .sheet(isPresented: $showStoryCreate) { 90 + StoryCreateView(client: client) { 91 + Task { await storyViewModel.load(auth: auth.authContext()) } 92 + } 60 93 } 61 94 } 62 95 } ··· 84 117 @State private var selectedProfileDid: String? 85 118 @State private var selectedHashtag: String? 86 119 let client: XRPCClient 120 + let storyAuthors: [GrainStoryAuthor] 121 + let userAvatar: String? 122 + let onStoryAuthorTap: (GrainStoryAuthor, Int) -> Void 123 + let onStoryCreateTap: () -> Void 124 + let onRefresh: (@Sendable () async -> Void)? 87 125 88 - init(client: XRPCClient, pinnedFeed: PinnedFeed, userDID: String? = nil) { 126 + init(client: XRPCClient, pinnedFeed: PinnedFeed, userDID: String? = nil, storyAuthors: [GrainStoryAuthor] = [], userAvatar: String? = nil, onStoryAuthorTap: @escaping (GrainStoryAuthor, Int) -> Void = { _, _ in }, onStoryCreateTap: @escaping () -> Void = {}, onRefresh: (@Sendable () async -> Void)? = nil) { 89 127 self.client = client 128 + self.storyAuthors = storyAuthors 129 + self.userAvatar = userAvatar 130 + self.onStoryAuthorTap = onStoryAuthorTap 131 + self.onStoryCreateTap = onStoryCreateTap 132 + self.onRefresh = onRefresh 90 133 _viewModel = State(initialValue: FeedViewModel(client: client, pinnedFeed: pinnedFeed, userDID: userDID)) 91 134 } 92 135 93 136 var body: some View { 94 137 ScrollView { 95 138 LazyVStack(spacing: 0) { 139 + if !storyAuthors.isEmpty { 140 + StoryStripView( 141 + authors: storyAuthors, 142 + userAvatar: userAvatar, 143 + onAuthorTap: onStoryAuthorTap, 144 + onCreateTap: onStoryCreateTap 145 + ) 146 + Divider() 147 + } 148 + 96 149 ForEach($viewModel.galleries) { $gallery in 97 150 GalleryCardView(gallery: $gallery, client: client, onNavigate: { 98 151 selectedUri = gallery.uri ··· 117 170 } 118 171 } 119 172 .refreshable { 120 - await viewModel.loadInitial(auth: auth.authContext()) 173 + async let feedRefresh: () = viewModel.loadInitial(auth: auth.authContext()) 174 + async let storyRefresh: ()? = onRefresh?() 175 + _ = await (feedRefresh, storyRefresh) 121 176 } 122 177 .navigationDestination(item: $selectedUri) { uri in 123 178 GalleryDetailView(client: client, galleryUri: uri)
-1
Grain/Views/MainTabView.swift
··· 7 7 @State private var showCreate = false 8 8 @State private var avatarTabImage: UIImage? 9 9 @State private var feedRefreshID = UUID() 10 - 11 10 var body: some View { 12 11 TabView(selection: $selectedTab) { 13 12 TabSection {
+24 -2
Grain/Views/Profile/ProfileView.swift
··· 3 3 4 4 struct ProfileView: View { 5 5 @Environment(AuthManager.self) private var auth 6 + @State private var showStoryViewer = false 6 7 @State private var viewModel: ProfileDetailViewModel 7 8 @State private var selectedGalleryUri: String? 8 9 @State private var selectedProfileDid: String? ··· 34 35 VStack(spacing: 16) { 35 36 // Avatar + name with glass header 36 37 VStack(spacing: 8) { 37 - AvatarView(url: profile.avatar, size: 80) 38 - .liquidGlassCircle() 38 + StoryRingView(hasStory: !viewModel.stories.isEmpty, size: 80) { 39 + AvatarView(url: profile.avatar, size: 80) 40 + .liquidGlassCircle() 41 + } 42 + .onTapGesture { 43 + if !viewModel.stories.isEmpty { 44 + showStoryViewer = true 45 + } 46 + } 39 47 40 48 Text(profile.displayName ?? profile.handle) 41 49 .font(.title2.bold()) ··· 142 150 } 143 151 .navigationDestination(item: $selectedHashtag) { tag in 144 152 HashtagFeedView(client: client, tag: tag) 153 + } 154 + .customFullScreenCover(isPresented: $showStoryViewer) { 155 + if let profile = viewModel.profile { 156 + StoryViewer( 157 + authors: [GrainStoryAuthor( 158 + profile: GrainProfile(cid: "", did: did, handle: profile.handle, displayName: profile.displayName, avatar: profile.avatar), 159 + storyCount: viewModel.stories.count, 160 + latestAt: viewModel.stories.first?.createdAt ?? "" 161 + )], 162 + startIndex: 0, 163 + client: client, 164 + onDismiss: { showStoryViewer = false } 165 + ) 166 + } 145 167 } 146 168 .background(Color(.systemBackground)) 147 169 .refreshable {
+221
Grain/Views/Stories/StoryCreateView.swift
··· 1 + import PhotosUI 2 + import SwiftUI 3 + 4 + struct StoryCreateView: View { 5 + @Environment(AuthManager.self) private var auth 6 + @Environment(\.dismiss) private var dismiss 7 + let client: XRPCClient 8 + var onCreated: (() -> Void)? 9 + 10 + @State private var selectedPhoto: PhotosPickerItem? 11 + @State private var photoData: Data? 12 + @State private var previewImage: UIImage? 13 + @State private var resolvedLocation: (h3: String, name: String, address: [String: AnyCodable]?)? 14 + @State private var locationQuery = "" 15 + @State private var locationSuggestions: [NominatimResult] = [] 16 + @State private var isSearchingLocation = false 17 + @State private var locationSearchTask: Task<Void, Never>? 18 + @State private var isUploading = false 19 + @State private var errorMessage: String? 20 + 21 + var body: some View { 22 + NavigationStack { 23 + Form { 24 + Section("Photo") { 25 + PhotosPicker(selection: $selectedPhoto, matching: .images) { 26 + if let previewImage { 27 + Image(uiImage: previewImage) 28 + .resizable() 29 + .scaledToFit() 30 + .frame(maxHeight: 300) 31 + .clipShape(RoundedRectangle(cornerRadius: 12)) 32 + } else { 33 + Label("Select Photo", systemImage: "camera") 34 + } 35 + } 36 + } 37 + 38 + Section("Location") { 39 + if let loc = resolvedLocation { 40 + HStack { 41 + Label(loc.name, systemImage: "mappin.and.ellipse") 42 + .font(.subheadline) 43 + .lineLimit(1) 44 + Spacer() 45 + Button { 46 + resolvedLocation = nil 47 + locationQuery = "" 48 + } label: { 49 + Image(systemName: "xmark.circle.fill") 50 + .foregroundStyle(.secondary) 51 + } 52 + } 53 + } else { 54 + HStack { 55 + Image(systemName: "magnifyingglass") 56 + .foregroundStyle(.secondary) 57 + TextField("Search for a location...", text: $locationQuery) 58 + .textInputAutocapitalization(.never) 59 + .onChange(of: locationQuery) { 60 + locationSearchTask?.cancel() 61 + let query = locationQuery 62 + locationSearchTask = Task { 63 + try? await Task.sleep(for: .milliseconds(300)) 64 + guard !Task.isCancelled else { return } 65 + await searchLocation(query: query) 66 + } 67 + } 68 + if isSearchingLocation { 69 + ProgressView() 70 + .controlSize(.small) 71 + } 72 + } 73 + 74 + ForEach(locationSuggestions, id: \.placeId) { result in 75 + Button { 76 + selectLocation(result) 77 + } label: { 78 + VStack(alignment: .leading, spacing: 2) { 79 + Text(result.name) 80 + .font(.subheadline) 81 + .foregroundStyle(.primary) 82 + if let context = result.context { 83 + Text(context) 84 + .font(.caption) 85 + .foregroundStyle(.secondary) 86 + } 87 + } 88 + } 89 + } 90 + } 91 + } 92 + 93 + if let errorMessage { 94 + Section { 95 + Text(errorMessage) 96 + .foregroundStyle(.red) 97 + .font(.caption) 98 + } 99 + } 100 + } 101 + .onChange(of: selectedPhoto) { 102 + Task { await loadPhoto() } 103 + } 104 + .navigationTitle("New Story") 105 + .toolbar { 106 + ToolbarItem(placement: .cancellationAction) { 107 + Button("Cancel") { dismiss() } 108 + } 109 + ToolbarItem(placement: .topBarTrailing) { 110 + Button { 111 + Task { await createStory() } 112 + } label: { 113 + if isUploading { 114 + ProgressView() 115 + } else { 116 + Text("Post") 117 + .bold() 118 + } 119 + } 120 + .disabled(previewImage == nil || isUploading) 121 + } 122 + } 123 + } 124 + } 125 + 126 + // MARK: - Photo Loading 127 + 128 + private func loadPhoto() async { 129 + guard let item = selectedPhoto, 130 + let data = try? await item.loadTransferable(type: Data.self), 131 + let image = UIImage(data: data) else { 132 + photoData = nil 133 + previewImage = nil 134 + return 135 + } 136 + photoData = data 137 + previewImage = image 138 + 139 + resolvedLocation = nil 140 + locationQuery = "" 141 + locationSuggestions = [] 142 + if let gps = ImageProcessing.extractGPS(from: data) { 143 + if let result = await LocationServices.reverseGeocode(latitude: gps.latitude, longitude: gps.longitude) { 144 + selectLocation(result) 145 + } 146 + } 147 + } 148 + 149 + // MARK: - Create 150 + 151 + private func createStory() async { 152 + guard let authContext = auth.authContext(), 153 + let repo = auth.userDID, 154 + let previewImage else { return } 155 + 156 + isUploading = true 157 + errorMessage = nil 158 + 159 + do { 160 + let (resized, size) = ImageProcessing.resizeImage(previewImage, maxDimension: 2000, maxBytes: 900_000) 161 + let response = try await client.uploadBlob(data: resized, mimeType: "image/jpeg", auth: authContext) 162 + 163 + let blobDict: [String: AnyCodable] = [ 164 + "$type": AnyCodable(response.blob.type ?? "blob"), 165 + "ref": AnyCodable(["$link": AnyCodable(response.blob.ref?.link ?? "")] as [String: AnyCodable]), 166 + "mimeType": AnyCodable(response.blob.mimeType ?? "image/jpeg"), 167 + "size": AnyCodable(response.blob.size ?? 0) 168 + ] 169 + 170 + var record: [String: AnyCodable] = [ 171 + "media": AnyCodable(blobDict), 172 + "aspectRatio": AnyCodable([ 173 + "width": AnyCodable(Int(size.width)), 174 + "height": AnyCodable(Int(size.height)) 175 + ] as [String: AnyCodable]), 176 + "createdAt": AnyCodable(ISO8601DateFormatter().string(from: Date())) 177 + ] 178 + 179 + if let loc = resolvedLocation { 180 + record["location"] = AnyCodable([ 181 + "value": AnyCodable(loc.h3), 182 + "name": AnyCodable(loc.name) 183 + ] as [String: AnyCodable]) 184 + if let addr = loc.address { 185 + record["address"] = AnyCodable(addr) 186 + } 187 + } 188 + 189 + _ = try await client.createRecord( 190 + collection: "social.grain.story", 191 + repo: repo, 192 + record: AnyCodable(record), 193 + auth: authContext 194 + ) 195 + 196 + onCreated?() 197 + dismiss() 198 + } catch let XRPCError.httpError(statusCode, body) { 199 + let bodyStr = body.flatMap { String(data: $0, encoding: .utf8) } ?? "no body" 200 + errorMessage = "HTTP \(statusCode): \(bodyStr)" 201 + } catch { 202 + errorMessage = error.localizedDescription 203 + } 204 + isUploading = false 205 + } 206 + 207 + // MARK: - Location 208 + 209 + private func searchLocation(query: String) async { 210 + isSearchingLocation = true 211 + defer { isSearchingLocation = false } 212 + locationSuggestions = await LocationServices.searchLocation(query: query) 213 + } 214 + 215 + private func selectLocation(_ result: NominatimResult) { 216 + let h3 = LocationServices.latLonToH3(latitude: result.latitude, longitude: result.longitude) 217 + resolvedLocation = (h3: h3, name: result.name, address: result.address) 218 + locationQuery = "" 219 + locationSuggestions = [] 220 + } 221 + }
+29
Grain/Views/Stories/StoryRingView.swift
··· 1 + import SwiftUI 2 + 3 + struct StoryRingView<Content: View>: View { 4 + let hasStory: Bool 5 + let size: CGFloat 6 + @ViewBuilder let content: () -> Content 7 + 8 + var body: some View { 9 + content() 10 + .overlay { 11 + if hasStory { 12 + Circle() 13 + .strokeBorder( 14 + LinearGradient( 15 + colors: [ 16 + Color(red: 0xc9/255, green: 0x7c/255, blue: 0xf8/255), 17 + Color(red: 0x85/255, green: 0xa1/255, blue: 0xff/255), 18 + Color(red: 0x5b/255, green: 0xf0/255, blue: 0xd6/255) 19 + ], 20 + startPoint: .topLeading, 21 + endPoint: .bottomTrailing 22 + ), 23 + lineWidth: 2.5 24 + ) 25 + .frame(width: size + 6, height: size + 6) 26 + } 27 + } 28 + } 29 + }
+49
Grain/Views/Stories/StoryStripView.swift
··· 1 + import SwiftUI 2 + 3 + struct StoryStripView: View { 4 + let authors: [GrainStoryAuthor] 5 + let userAvatar: String? 6 + let onAuthorTap: (GrainStoryAuthor, Int) -> Void 7 + let onCreateTap: () -> Void 8 + 9 + private let avatarSize: CGFloat = 56 10 + 11 + var body: some View { 12 + ScrollView(.horizontal, showsIndicators: false) { 13 + HStack(spacing: 16) { 14 + // Create button 15 + VStack(spacing: 4) { 16 + ZStack(alignment: .bottomTrailing) { 17 + AvatarView(url: userAvatar, size: avatarSize) 18 + Image(systemName: "plus.circle.fill") 19 + .font(.system(size: 18)) 20 + .foregroundStyle(.white, Color("AccentColor")) 21 + .offset(x: 2, y: 2) 22 + } 23 + Text("Your story") 24 + .font(.caption2) 25 + .foregroundStyle(.secondary) 26 + .lineLimit(1) 27 + } 28 + .onTapGesture { onCreateTap() } 29 + 30 + // Author avatars 31 + ForEach(Array(authors.enumerated()), id: \.element.id) { index, author in 32 + VStack(spacing: 4) { 33 + StoryRingView(hasStory: true, size: avatarSize) { 34 + AvatarView(url: author.profile.avatar, size: avatarSize) 35 + } 36 + Text(author.profile.displayName ?? author.profile.handle) 37 + .font(.caption2) 38 + .foregroundStyle(.secondary) 39 + .lineLimit(1) 40 + .frame(width: avatarSize + 8) 41 + } 42 + .onTapGesture { onAuthorTap(author, index) } 43 + } 44 + } 45 + .padding(.horizontal) 46 + .padding(.vertical, 8) 47 + } 48 + } 49 + }
+278 -54
Grain/Views/Stories/StoryViewer.swift
··· 2 2 import NukeUI 3 3 4 4 struct StoryViewer: View { 5 - let stories: [GrainStory] 6 - @State private var currentIndex: Int 7 - @Environment(\.dismiss) private var dismiss 5 + @Environment(AuthManager.self) private var auth 6 + let authors: [GrainStoryAuthor] 7 + let client: XRPCClient 8 + var onProfileTap: ((String) -> Void)? 9 + var onDismiss: (() -> Void)? 10 + @State private var currentAuthorIndex: Int 11 + @State private var currentStoryIndex = 0 12 + @State private var stories: [GrainStory] = [] 13 + @State private var isLoadingStories = false 14 + @State private var progress: CGFloat = 0 15 + @State private var timerTask: Task<Void, Never>? 16 + @State private var showDeleteConfirm = false 17 + @State private var lastNavTime: Date = .distantPast 8 18 9 - init(stories: [GrainStory], startIndex: Int = 0) { 10 - self.stories = stories 11 - _currentIndex = State(initialValue: startIndex) 19 + private let storyDuration: TimeInterval = 5.0 20 + 21 + init(authors: [GrainStoryAuthor], startIndex: Int = 0, client: XRPCClient, onProfileTap: ((String) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { 22 + self.authors = authors 23 + self.client = client 24 + self.onProfileTap = onProfileTap 25 + self.onDismiss = onDismiss 26 + _currentAuthorIndex = State(initialValue: startIndex) 27 + } 28 + 29 + private var currentStory: GrainStory? { 30 + guard currentStoryIndex < stories.count else { return nil } 31 + return stories[currentStoryIndex] 12 32 } 13 33 14 34 var body: some View { 15 35 ZStack { 16 36 Color.black.ignoresSafeArea() 17 37 18 - if currentIndex < stories.count { 19 - let story = stories[currentIndex] 20 - 38 + if let story = currentStory { 39 + // Story image 21 40 LazyImage(url: URL(string: story.fullsize)) { state in 22 41 if let image = state.image { 23 42 image ··· 29 48 } 30 49 } 31 50 32 - // Progress indicators + creator info 33 - VStack { 51 + // Overlay UI 52 + VStack(spacing: 0) { 53 + // Progress bars 34 54 HStack(spacing: 4) { 35 55 ForEach(0..<stories.count, id: \.self) { index in 36 - Capsule() 37 - .fill(index <= currentIndex ? Color.white : Color.white.opacity(0.3)) 38 - .frame(height: 2) 56 + GeometryReader { geo in 57 + Capsule() 58 + .fill(Color.white.opacity(0.3)) 59 + Capsule() 60 + .fill(Color.white) 61 + .frame(width: barWidth(for: index, totalWidth: geo.size.width)) 62 + } 63 + .frame(height: 2) 39 64 } 40 65 } 41 66 .padding(.horizontal) 42 67 .padding(.top, 8) 43 68 44 - // Creator info with glass effect 45 - HStack { 46 - AvatarView(url: story.creator.avatar, size: 32) 47 - Text(story.creator.displayName ?? story.creator.handle) 48 - .font(.subheadline.bold()) 49 - .foregroundStyle(.white) 69 + // Creator info 70 + HStack(alignment: .center) { 71 + Button { 72 + close() 73 + onProfileTap?(story.creator.did) 74 + } label: { 75 + HStack(alignment: .center, spacing: 8) { 76 + AvatarView(url: story.creator.avatar, size: 32) 77 + VStack(alignment: .leading, spacing: 0) { 78 + Text(story.creator.displayName ?? story.creator.handle) 79 + .font(.subheadline.bold()) 80 + .foregroundStyle(.white) 81 + Text(relativeTime(story.createdAt)) 82 + .font(.caption2) 83 + .foregroundStyle(.white.opacity(0.7)) 84 + } 85 + } 86 + } 50 87 Spacer() 51 - Button { dismiss() } label: { 88 + 89 + if story.creator.did == auth.userDID { 90 + Button { 91 + timerTask?.cancel() 92 + showDeleteConfirm = true 93 + } label: { 94 + Image(systemName: "trash") 95 + .foregroundStyle(.white) 96 + } 97 + } 98 + 99 + Button { close() } label: { 52 100 Image(systemName: "xmark") 53 101 .foregroundStyle(.white) 102 + .font(.body.weight(.semibold)) 54 103 } 55 104 } 56 105 .padding(.horizontal, 16) 57 106 .padding(.vertical, 8) 58 - .liquidGlass() 59 - .padding(.horizontal) 60 107 61 108 Spacer() 62 109 63 - // Location pill at bottom 64 - if let address = story.address { 110 + // Location pill 111 + if let locationText = storyLocationText(story) { 65 112 HStack { 66 - Image(systemName: "location.fill") 67 - Text(address.locality ?? address.name ?? address.country) 113 + HStack(spacing: 4) { 114 + Image(systemName: "location.fill") 115 + Text(locationText) 116 + } 117 + .font(.caption) 118 + .foregroundStyle(.white) 119 + .padding(.horizontal, 12) 120 + .padding(.vertical, 6) 121 + .background(.ultraThinMaterial, in: Capsule()) 122 + Spacer() 68 123 } 69 - .font(.caption) 70 - .foregroundStyle(.white) 71 - .padding(.horizontal, 12) 72 - .padding(.vertical, 6) 73 - .liquidGlass() 124 + .padding(.horizontal) 74 125 .padding(.bottom, 32) 75 126 } 76 127 } 77 - } 78 - } 79 - .contentShape(Rectangle()) 80 - .gesture( 81 - DragGesture(minimumDistance: 0) 82 - .onEnded { value in 83 - if value.translation.width < -50 { 84 - if currentIndex < stories.count - 1 { 85 - currentIndex += 1 86 - } else { 87 - dismiss() 88 - } 89 - } else if value.translation.width > 50 { 90 - if currentIndex > 0 { 91 - currentIndex -= 1 92 - } 93 - } else { 94 - if value.startLocation.x > UIScreen.main.bounds.width / 2 { 95 - if currentIndex < stories.count - 1 { currentIndex += 1 } else { dismiss() } 96 - } else { 97 - if currentIndex > 0 { currentIndex -= 1 } 128 + 129 + // Tap zones (below header area) 130 + VStack(spacing: 0) { 131 + Color.clear 132 + .frame(height: 80) 133 + .allowsHitTesting(false) 134 + GeometryReader { geo in 135 + HStack(spacing: 0) { 136 + Color.clear 137 + .contentShape(Rectangle()) 138 + .onTapGesture { goToPrevious() } 139 + .frame(width: geo.size.width / 3) 140 + Color.clear 141 + .contentShape(Rectangle()) 142 + .onTapGesture { goToNext() } 143 + .frame(maxWidth: .infinity) 98 144 } 99 145 } 146 + .simultaneousGesture( 147 + DragGesture(minimumDistance: 80) 148 + .onEnded { value in 149 + if value.translation.width < -80 { 150 + goToNextAuthor() 151 + } else if value.translation.width > 80 { 152 + goToPreviousAuthor() 153 + } 154 + } 155 + ) 100 156 } 101 - ) 157 + } else if isLoadingStories { 158 + ProgressView() 159 + .tint(.white) 160 + } 161 + } 102 162 .statusBarHidden() 163 + .confirmationDialog("Delete this story?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { 164 + Button("Delete", role: .destructive) { 165 + if let story = currentStory { 166 + Task { await deleteStory(story) } 167 + } 168 + } 169 + Button("Cancel", role: .cancel) { 170 + startTimer() 171 + } 172 + } 173 + .task { 174 + await loadStoriesForCurrentAuthor() 175 + } 176 + } 177 + 178 + // MARK: - Progress Bar 179 + 180 + private func barWidth(for index: Int, totalWidth: CGFloat) -> CGFloat { 181 + if index < currentStoryIndex { 182 + return totalWidth 183 + } else if index == currentStoryIndex { 184 + return totalWidth * progress 185 + } else { 186 + return 0 187 + } 188 + } 189 + 190 + // MARK: - Timer 191 + 192 + private func startTimer() { 193 + timerTask?.cancel() 194 + progress = 0 195 + timerTask = Task { 196 + let tickInterval: TimeInterval = 0.05 197 + let totalTicks = Int(storyDuration / tickInterval) 198 + for tick in 0...totalTicks { 199 + try? await Task.sleep(for: .milliseconds(Int(tickInterval * 1000))) 200 + guard !Task.isCancelled else { return } 201 + progress = CGFloat(tick) / CGFloat(totalTicks) 202 + } 203 + guard !Task.isCancelled else { return } 204 + goToNext() 205 + } 206 + } 207 + 208 + private func close() { 209 + timerTask?.cancel() 210 + onDismiss?() 211 + } 212 + 213 + // MARK: - Navigation 214 + 215 + private func goToNext() { 216 + guard !isLoadingStories, !stories.isEmpty else { return } 217 + guard Date().timeIntervalSince(lastNavTime) > 0.3 else { return } 218 + timerTask?.cancel() 219 + lastNavTime = Date() 220 + if currentStoryIndex < stories.count - 1 { 221 + currentStoryIndex += 1 222 + startTimer() 223 + } else { 224 + goToNextAuthor() 225 + } 226 + } 227 + 228 + private func goToPrevious() { 229 + guard !isLoadingStories, !stories.isEmpty else { return } 230 + guard Date().timeIntervalSince(lastNavTime) > 0.3 else { return } 231 + timerTask?.cancel() 232 + lastNavTime = Date() 233 + if currentStoryIndex > 0 { 234 + currentStoryIndex -= 1 235 + startTimer() 236 + } else { 237 + goToPreviousAuthor() 238 + } 239 + } 240 + 241 + private func goToNextAuthor() { 242 + if currentAuthorIndex < authors.count - 1 { 243 + currentAuthorIndex += 1 244 + currentStoryIndex = 0 245 + stories = [] 246 + isLoadingStories = true 247 + timerTask?.cancel() 248 + Task { await loadStoriesForCurrentAuthor() } 249 + } else { 250 + close() 251 + } 252 + } 253 + 254 + private func goToPreviousAuthor() { 255 + if currentAuthorIndex > 0 { 256 + currentAuthorIndex -= 1 257 + currentStoryIndex = 0 258 + stories = [] 259 + isLoadingStories = true 260 + timerTask?.cancel() 261 + Task { await loadStoriesForCurrentAuthor() } 262 + } 263 + } 264 + 265 + // MARK: - Data 266 + 267 + private func loadStoriesForCurrentAuthor() async { 268 + guard currentAuthorIndex < authors.count else { return } 269 + let did = authors[currentAuthorIndex].profile.did 270 + isLoadingStories = true 271 + timerTask?.cancel() 272 + 273 + do { 274 + let response = try await client.getStories(actor: did, auth: auth.authContext()) 275 + stories = response.stories 276 + currentStoryIndex = 0 277 + startTimer() 278 + } catch { 279 + stories = [] 280 + } 281 + isLoadingStories = false 282 + } 283 + 284 + private func deleteStory(_ story: GrainStory) async { 285 + guard let authContext = auth.authContext() else { return } 286 + let rkey = story.uri.split(separator: "/").last.map(String.init) ?? "" 287 + do { 288 + try await client.deleteRecord(collection: "social.grain.story", rkey: rkey, auth: authContext) 289 + stories.removeAll { $0.uri == story.uri } 290 + if stories.isEmpty { 291 + goToNextAuthor() 292 + } else { 293 + currentStoryIndex = min(currentStoryIndex, stories.count - 1) 294 + startTimer() 295 + } 296 + } catch { 297 + // Silently fail 298 + } 299 + } 300 + 301 + private func storyLocationText(_ story: GrainStory) -> String? { 302 + // Prefer location.name as the primary display (e.g. "Fimmvörðuháls Trail") 303 + if let name = story.location?.name, !name.isEmpty { 304 + return name 305 + } 306 + if let address = story.address { 307 + var parts: [String] = [] 308 + if let name = address.name { parts.append(name) } 309 + else if let street = address.street { parts.append(street) } 310 + else if let locality = address.locality { parts.append(locality) } 311 + if let region = address.region, region != parts.first { parts.append(region) } 312 + if let locality = address.locality, !parts.contains(locality) { parts.append(locality) } 313 + if parts.isEmpty { parts.append(address.country) } 314 + return parts.joined(separator: ", ") 315 + } 316 + return nil 317 + } 318 + 319 + private func relativeTime(_ dateString: String) -> String { 320 + let formatter = ISO8601DateFormatter() 321 + guard let date = formatter.date(from: dateString) else { return "" } 322 + let interval = Date().timeIntervalSince(date) 323 + if interval < 60 { return "now" } 324 + if interval < 3600 { return "\(Int(interval / 60))m" } 325 + if interval < 86400 { return "\(Int(interval / 3600))h" } 326 + return "\(Int(interval / 86400))d" 103 327 } 104 328 }
+90
docs/plans/2026-03-31-stories-feature-design.md
··· 1 + # Stories Feature Design 2 + 3 + **Goal:** Add Instagram-style ephemeral stories to the native iOS app, matching the web client's functionality for viewing, browsing, and creating stories. 4 + 5 + **Scope:** Story strip on feed, story viewer, profile story indicator, story creation. Story archive is out of scope. 6 + 7 + --- 8 + 9 + ## 1. Story Strip (Feed) 10 + 11 + Horizontal `ScrollView` at the top of `FeedView`, above gallery cards. 12 + 13 + - First item: "+" button (opens `StoryCreateView` sheet) for authenticated users 14 + - Remaining items: circular avatars with gradient ring (purple > accent > cyan) for users with active stories (24h window) 15 + - Display name label below each avatar 16 + - Tapping an avatar opens `StoryViewer` as a fullscreen cover, starting at that author 17 + - Data: `getStoryAuthors()` endpoint, fetched on load and pull-to-refresh 18 + 19 + **New files:** 20 + - `Grain/Views/Stories/StoryStripView.swift` — horizontal avatar strip component 21 + - `Grain/ViewModels/StoryStripViewModel.swift` — fetches story authors 22 + 23 + **Shared component:** 24 + - `Grain/Views/Stories/StoryRingView.swift` — gradient ring wrapper, reused on profile avatar 25 + 26 + ## 2. Story Viewer 27 + 28 + Wire up the existing `StoryViewer.swift` and present it from feed and profile. 29 + 30 + - Presented as `.fullScreenCover` 31 + - Receives full authors list + starting index to enable swiping between authors 32 + - Each author's stories fetched on demand via `getStories(actor:)` 33 + - Auto-advance: 5-second timer per story, animated progress bars at top 34 + - Navigation: tap left 1/3 = previous, tap right 2/3 = next 35 + - Swipe left/right to jump between authors 36 + - Dismisses after last story of last author 37 + - Delete button on own stories via `deleteRecord` 38 + - Status bar hidden for immersive experience 39 + 40 + **Modified files:** 41 + - `Grain/Views/Stories/StoryViewer.swift` — enhance with auto-advance timer, author list navigation 42 + - `Grain/Views/Feed/FeedView.swift` — add fullscreen cover presentation 43 + - `Grain/Views/Profile/ProfileView.swift` — add fullscreen cover on avatar tap 44 + 45 + ## 3. Profile Story Indicator 46 + 47 + When a user has active stories, their profile avatar shows a gradient ring and becomes tappable to open the viewer. 48 + 49 + - `ProfileDetailViewModel` already fetches stories — use `viewModel.stories.isEmpty` to determine ring visibility 50 + - Wrap `AvatarView` with `StoryRingView` when stories exist 51 + - Tap avatar to open `StoryViewer` as fullscreen cover 52 + - No ring and no tap action when no stories 53 + 54 + **Modified files:** 55 + - `Grain/Views/Profile/ProfileView.swift` — conditional ring + tap gesture on avatar 56 + 57 + ## 4. Story Creation 58 + 59 + Sheet presented from the "+" button in the story strip. Reuses photo processing from `CreateGalleryView`. 60 + 61 + - Single photo selection via `PhotosPicker` 62 + - Photo preview after selection 63 + - Image resize: max 2000px, max 5MB with JPEG quality binary search 64 + - Location auto-populated from EXIF GPS via reverse geocode (Nominatim), converted to H3 index 65 + - Editable location text field 66 + - Optional "Post to Bluesky" toggle 67 + - Post: `uploadBlob` then `createRecord` with collection `social.grain.story` 68 + - Record fields: media (blob ref), aspectRatio, location (H3 value + name), address, createdAt 69 + - On success: refresh story strip, dismiss sheet 70 + 71 + **New files:** 72 + - `Grain/Views/Stories/StoryCreateView.swift` 73 + 74 + **Reused from gallery creation:** 75 + - Photo processing / resize utilities 76 + - EXIF GPS extraction 77 + - Reverse geocoding 78 + - H3 conversion (SwiftyH3) 79 + 80 + --- 81 + 82 + ## Existing Infrastructure 83 + 84 + Already built and ready to use: 85 + 86 + - **Models:** `GrainStory`, `GrainStoryAuthor`, `StoryRecord` (in `Grain/Models/`) 87 + - **API endpoints:** `getStories`, `getStory`, `getStoryArchive`, `getStoryAuthors` (in `Grain/API/Endpoints/StoryEndpoints.swift`) 88 + - **Viewer component:** `StoryViewer.swift` (needs enhancement but base exists) 89 + - **OAuth scope:** `repo:social.grain.story` already included 90 + - **Photo processing:** resize, EXIF extraction, blob upload all exist in `CreateGalleryView`