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: Create Story intent + home-screen quick actions (#21)

* chore: stop tracking generated Info.plist, clean .gitignore

Info.plist is emitted by XcodeGen from project.yml on every just
generate — tracking it caused silent drift when edits were made
directly to Info.plist instead of project.yml. Also removes a
leftover merge conflict marker around .zed/sim.

* feat: add Create Story app intent

Adds a CreateStoryIntent alongside the existing CreateGalleryIntent
and reorders the AppShortcuts list so the two create actions surface
first in Spotlight and the Shortcuts app. FeedView's showStoryCreate
is lifted to a Binding so MainTabView can toggle it in response to
the intent.

* chore: refresh App Shortcuts index on every launch

Calls GrainShortcuts.updateAppShortcutParameters() from GrainApp so
iOS re-reads the AppShortcutsProvider regardless of auth state.
Without this, order/intent changes stay stale until the app is
deleted and reinstalled. Runs at .background priority so it doesn't
compete with launch-critical work.

* feat: add home-screen quick actions for Create Story and Create Post

Declares two static UIApplicationShortcutItems in project.yml so they
appear on long-press of the app icon. GrainSceneDelegate handles both
cold-launch (scene:willConnectTo:options:) and warm activation
(windowScene:performActionFor:) by posting into the existing
grainShortcutAction notification pipeline, reusing the same dispatch
that Siri/Spotlight intents already use.

authored by

Hima and committed by
GitHub
71b32fbb 38eb2e8d

+108 -83
+3 -3
.gitignore
··· 41 41 # Zed personal config 42 42 .zed/keymap.json 43 43 .zed/tasks.json 44 - <<<<<<< HEAD 45 44 .zed/sim 46 - ======= 47 - >>>>>>> 9d69c0f (feat: gallery editor — photo strip, reorder, captions, and sheet flow (#11)) 48 45 buildServer.json 46 + 47 + # Generated by XcodeGen from project.yml 48 + Grain/Info.plist
+10
Grain/AppDelegate.swift
··· 26 26 27 27 func application( 28 28 _: UIApplication, 29 + configurationForConnecting connectingSceneSession: UISceneSession, 30 + options _: UIScene.ConnectionOptions 31 + ) -> UISceneConfiguration { 32 + let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) 33 + config.delegateClass = GrainSceneDelegate.self 34 + return config 35 + } 36 + 37 + func application( 38 + _: UIApplication, 29 39 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data 30 40 ) { 31 41 pushManager?.didRegisterForRemoteNotifications(deviceToken: deviceToken)
+4
Grain/GrainApp.swift
··· 1 + import AppIntents 1 2 import Nuke 2 3 import os 3 4 import SwiftUI ··· 94 95 if let deepLink = DeepLink.from(url: url) { 95 96 pendingDeepLink = deepLink 96 97 } 98 + } 99 + .task(priority: .background) { 100 + GrainShortcuts.updateAppShortcutParameters() 97 101 } 98 102 .preferredColorScheme(colorScheme) 99 103 }
-66
Grain/Info.plist
··· 1 - <?xml version="1.0" encoding="UTF-8"?> 2 - <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 - <plist version="1.0"> 4 - <dict> 5 - <key>CFBundleDevelopmentRegion</key> 6 - <string>$(DEVELOPMENT_LANGUAGE)</string> 7 - <key>CFBundleExecutable</key> 8 - <string>$(EXECUTABLE_NAME)</string> 9 - <key>CFBundleIconName</key> 10 - <string>AppIcon</string> 11 - <key>CFBundleIdentifier</key> 12 - <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> 13 - <key>CFBundleInfoDictionaryVersion</key> 14 - <string>6.0</string> 15 - <key>CFBundleName</key> 16 - <string>$(PRODUCT_NAME)</string> 17 - <key>CFBundlePackageType</key> 18 - <string>APPL</string> 19 - <key>CFBundleShortVersionString</key> 20 - <string>$(MARKETING_VERSION)</string> 21 - <key>CFBundleURLTypes</key> 22 - <array> 23 - <dict> 24 - <key>CFBundleURLSchemes</key> 25 - <array> 26 - <string>grain</string> 27 - </array> 28 - </dict> 29 - </array> 30 - <key>CFBundleVersion</key> 31 - <string>$(CURRENT_PROJECT_VERSION)</string> 32 - <key>NSCameraUsageDescription</key> 33 - <string>Grain needs camera access to take photos for your stories and galleries.</string> 34 - <key>NSPhotoLibraryUsageDescription</key> 35 - <string>Grain needs photo library access to select photos for your galleries and stories.</string> 36 - <key>OSLogPreferences</key> 37 - <dict> 38 - <key>social.grain.grain</key> 39 - <dict> 40 - <key>DEFAULT-OPTIONS</key> 41 - <dict> 42 - <key>Level</key> 43 - <dict> 44 - <key>Enable</key> 45 - <string>$(GRAIN_OS_LOG_LEVEL)</string> 46 - <key>Persist</key> 47 - <string>$(GRAIN_OS_LOG_LEVEL)</string> 48 - </dict> 49 - </dict> 50 - </dict> 51 - </dict> 52 - <key>UIAppFonts</key> 53 - <array> 54 - <string>Syne-Variable.ttf</string> 55 - </array> 56 - <key>UILaunchScreen</key> 57 - <dict/> 58 - <key>UISupportedInterfaceOrientations</key> 59 - <array> 60 - <string>UIInterfaceOrientationPortrait</string> 61 - <string>UIInterfaceOrientationPortraitUpsideDown</string> 62 - <string>UIInterfaceOrientationLandscapeLeft</string> 63 - <string>UIInterfaceOrientationLandscapeRight</string> 64 - </array> 65 - </dict> 66 - </plist>
+38
Grain/SceneDelegate.swift
··· 1 + import UIKit 2 + 3 + @MainActor 4 + class GrainSceneDelegate: NSObject, UIWindowSceneDelegate { 5 + var window: UIWindow? 6 + 7 + func scene( 8 + _: UIScene, 9 + willConnectTo _: UISceneSession, 10 + options connectionOptions: UIScene.ConnectionOptions 11 + ) { 12 + if let item = connectionOptions.shortcutItem { 13 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 14 + Self.dispatch(type: item.type) 15 + } 16 + } 17 + } 18 + 19 + func windowScene( 20 + _: UIWindowScene, 21 + performActionFor shortcutItem: UIApplicationShortcutItem, 22 + completionHandler: @escaping (Bool) -> Void 23 + ) { 24 + Self.dispatch(type: shortcutItem.type) 25 + completionHandler(true) 26 + } 27 + 28 + private static func dispatch(type: String) { 29 + let action: GrainShortcutAction? = switch type { 30 + case "social.grain.shortcut.createStory": .createStory 31 + case "social.grain.shortcut.createGallery": .createGallery 32 + default: nil 33 + } 34 + if let action { 35 + NotificationCenter.default.post(name: .grainShortcutAction, object: action.rawValue) 36 + } 37 + } 38 + }
+33 -11
Grain/Shortcuts/AppShortcuts.swift
··· 8 8 } 9 9 10 10 enum GrainShortcutAction: String { 11 - case feed, search, notifications, profile, createGallery 11 + case feed, search, notifications, profile, createGallery, createStory 12 12 } 13 13 14 14 // MARK: - Intents ··· 61 61 } 62 62 } 63 63 64 + struct CreateStoryIntent: AppIntent { 65 + static let title: LocalizedStringResource = "Create Story" 66 + static let description = IntentDescription("Start posting a new story in Grain.") 67 + static let openAppWhenRun = true 68 + 69 + @MainActor 70 + func perform() async throws -> some IntentResult { 71 + NotificationCenter.default.post(name: .grainShortcutAction, object: GrainShortcutAction.createStory.rawValue) 72 + return .result() 73 + } 74 + } 75 + 64 76 struct CreateGalleryIntent: AppIntent { 65 77 static let title: LocalizedStringResource = "Create Gallery" 66 78 static let description = IntentDescription("Start posting a new photo gallery in Grain.") ··· 78 90 struct GrainShortcuts: AppShortcutsProvider { 79 91 static var appShortcuts: [AppShortcut] { 80 92 AppShortcut( 93 + intent: CreateStoryIntent(), 94 + phrases: [ 95 + "Create a story in \(.applicationName)", 96 + "New \(.applicationName) story", 97 + "Post a \(.applicationName) story", 98 + ], 99 + shortTitle: "Create Story", 100 + systemImageName: "plus.circle" 101 + ) 102 + AppShortcut( 103 + intent: CreateGalleryIntent(), 104 + phrases: [ 105 + "Create a gallery in \(.applicationName)", 106 + "New \(.applicationName) gallery", 107 + "Post to \(.applicationName)", 108 + ], 109 + shortTitle: "Create Gallery", 110 + systemImageName: "plus.square.on.square" 111 + ) 112 + AppShortcut( 81 113 intent: OpenFeedIntent(), 82 114 phrases: [ 83 115 "Open feed in \(.applicationName)", ··· 112 144 ], 113 145 shortTitle: "My Profile", 114 146 systemImageName: "person" 115 - ) 116 - AppShortcut( 117 - intent: CreateGalleryIntent(), 118 - phrases: [ 119 - "Create a gallery in \(.applicationName)", 120 - "New \(.applicationName) gallery", 121 - "Post to \(.applicationName)", 122 - ], 123 - shortTitle: "Create Gallery", 124 - systemImageName: "plus.square.on.square" 125 147 ) 126 148 } 127 149 }
+8 -2
Grain/Views/Feed/FeedView.swift
··· 11 11 @State private var prefsViewModel: FeedPreferencesViewModel 12 12 @State private var storyViewModel: StoryStripViewModel 13 13 @State private var storyViewerDid: String? 14 - @State private var showStoryCreate = false 14 + @Binding var showStoryCreate: Bool 15 15 @State private var deepLinkProfileDid: String? 16 16 @State private var deepLinkGalleryUri: String? 17 17 @State private var deepLinkStoryAuthor: GrainStoryAuthor? ··· 23 23 @Binding var pendingDeepLink: DeepLink? 24 24 @Binding var showCreate: Bool 25 25 26 - init(client: XRPCClient, pendingDeepLink: Binding<DeepLink?> = .constant(nil), showCreate: Binding<Bool> = .constant(false)) { 26 + init( 27 + client: XRPCClient, 28 + pendingDeepLink: Binding<DeepLink?> = .constant(nil), 29 + showCreate: Binding<Bool> = .constant(false), 30 + showStoryCreate: Binding<Bool> = .constant(false) 31 + ) { 27 32 self.client = client 28 33 _pendingDeepLink = pendingDeepLink 29 34 _showCreate = showCreate 35 + _showStoryCreate = showStoryCreate 30 36 _prefsViewModel = State(initialValue: FeedPreferencesViewModel(client: client)) 31 37 _storyViewModel = State(initialValue: StoryStripViewModel(client: client)) 32 38 }
+5 -1
Grain/Views/MainTabView.swift
··· 18 18 @State private var commentPresenter = StoryCommentPresenter() 19 19 @State private var client: XRPCClient? 20 20 @State private var showCreate = false 21 + @State private var showStoryCreate = false 21 22 @State private var avatarTabImage: UIImage? 22 23 @State private var feedRefreshID = UUID() 23 24 @State private var notificationsVM = NotificationsViewModel(client: XRPCClient(baseURL: AuthManager.serverURL)) ··· 53 54 let _ = launchSignposter.emitEvent("TabViewBodyBegin") 54 55 TabView(selection: $selectedTab) { 55 56 Tab("Feed", systemImage: "photo.on.rectangle", value: AppTab.feed) { 56 - FeedView(client: client, pendingDeepLink: $pendingDeepLink, showCreate: $showCreate) 57 + FeedView(client: client, pendingDeepLink: $pendingDeepLink, showCreate: $showCreate, showStoryCreate: $showStoryCreate) 57 58 .id(feedRefreshID) 58 59 } 59 60 ··· 175 176 case .createGallery: 176 177 selectedTab = .feed 177 178 showCreate = true 179 + case .createStory: 180 + selectedTab = .feed 181 + showStoryCreate = true 178 182 } 179 183 } 180 184 }
+7
project.yml
··· 87 87 Level: 88 88 Enable: "$(GRAIN_OS_LOG_LEVEL)" 89 89 Persist: "$(GRAIN_OS_LOG_LEVEL)" 90 + UIApplicationShortcutItems: 91 + - UIApplicationShortcutItemType: social.grain.shortcut.createStory 92 + UIApplicationShortcutItemTitle: Create Story 93 + UIApplicationShortcutItemIconSymbolName: plus.circle 94 + - UIApplicationShortcutItemType: social.grain.shortcut.createGallery 95 + UIApplicationShortcutItemTitle: Create Post 96 + UIApplicationShortcutItemIconSymbolName: plus.square.on.square 90 97 91 98 GrainTests: 92 99 type: bundle.unit-test