Share Extension

The StreamChat iOS SDK provides possibilities for deeper integration with the operating system, helping your users to engage with your app even when it is not active. One example is the “Share” extension, which allows users to share content via third-party apps from other apps, such as Apple’s native Photos app.

Chat Share Extension

In this guide, we will show you how to create such extension, which will share photos from Apple’s share sheet to the Stream chat.

In order to get started, you need to create a share extension for your project in Xcode:

Screenshot shows an Xcode screen creating share extension

This action will generate a new target and a starting point to implement the share extension. In this example, we will not use a Storyboard, so you can delete that file from the generated code. The Info.plist file needs to be updated accordingly, by removing the storyboard entry. Its contents should look something like this:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>NSExtension</key>
    <dict>
        <key>NSExtensionAttributes</key>
        <dict>
            <key>IntentsSupported</key>
            <array>
                <string>INSendMessageIntent</string>
            </array>
            <key>NSExtensionActivationRule</key>
            <dict>
                <key>NSExtensionActivationSupportsImageWithMaxCount</key>
                <integer>10</integer>
            </dict>
        </dict>
        <key>NSExtensionPointIdentifier</key>
        <string>com.apple.share-services</string>
        <key>NSExtensionPrincipalClass</key>
        <string>DemoShare.ShareViewController</string>
    </dict>
</dict>
</plist>

Note that the NSExtensionPrincipalClass’s name consists of the target name and the name of the class that will be called when the user selects our extension from Apple’s native share sheet.

In the NSExtensionActivationRule, we specify how our extension can be activated. Since we want to share images, we will use NSExtensionActivationSupportsImageWithMaxCount, with the value of 10. That means that our extension would be shown when up to 10 images are selected in the photos app. You can configure this value according to your requirements.

Setting up App Groups

To support sharing from an extension, we would need to take the currently logged in user in the main app. To do this, we need to create a shared container between the main app and the service extension. You can do this by adding an app group capability within your projects “Signing & Capabilities” section.

Make sure to use the same group for both targets (app and extension). When you have both configured, you need to adjust your ChatClient setup and add the following code to the configuration object:

var config = ChatClientConfig(apiKey: .init("<# Your API Key Here #>"))
config.applicationGroupIdentifier = "group.x.y.z"

let client = ChatClient(config: config)

In order for this to work correctly, you need to do it in the service extension and in the application.

ShareViewController

Next, let’s implement the ShareViewController, which is called when the user selects our extension for sharing. In the generated code, the ShareViewController is subclassing the SLComposeServiceViewController, which provides a default UI with a text field and the images to share.

However, in our case we want to have the possibility to select a channel where we want to share the message, so we will change the subclass to UIViewController instead, and provide our custom UI.

Here’s how the end result will look like:

Screenshot showing the share sheet UI

In this example, we will use SwiftUI views, wrapped in a UIHostingController. However, you can build your UI entirely in UIKit as well.

Here’s how the ShareViewController’s implementation looks like.

import UIKit
import Social
import StreamChat
import SwiftUI
import CoreServices

class ShareViewController: UIViewController {
        
    override func viewDidLoad() {
        super.viewDidLoad()
        
        guard let userCredentials = UserDefaults.shared.currentUser else {
            self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
            return
        }
                
        self.view.backgroundColor = .systemBackground
        let demoShareView = UIHostingController(
            rootView: DemoShareView(
                userCredentials: userCredentials,
                extensionContext: self.extensionContext
            )
        ).view!
        
        demoShareView.frame = view.frame
        self.view.addSubview(demoShareView)
    }
}

First, we try to fetch the current user. Here UserDefaults are used for simplicity. However, in your app, you should store the currently logged in user in a more secure storage, such as the keychain.

Next, we create a UIHostingController with a root view called DemoShareView, and we add this as a subview to the view controller’s view.

DemoShareView

Let’s check the DemoShareView implementation next.

struct DemoShareView: View {
    
    @StateObject var viewModel: DemoShareViewModel
        
    init(
        userCredentials: UserCredentials,
        extensionContext: NSExtensionContext?
    ) {
        let vm = DemoShareViewModel(
            userCredentials: userCredentials,
            extensionContext: extensionContext
        )
        _viewModel = StateObject(wrappedValue: vm)
    }
    
    var body: some View {
        VStack(spacing: 0) {
            TopView(viewModel: viewModel)
            
            VStack(alignment: .center) {
                if viewModel.images.count == 1 {
                    ImageToShareView(image: viewModel.images[0])
                } else {
                    ScrollView(.horizontal) {
                        HStack {
                            ForEach(viewModel.images, id: \.self) { image in
                                ImageToShareView(image: image, contentMode: .fill)
                            }
                        }
                    }
                }
                
                TextField("Write a message...", text: $viewModel.text)
                    .padding(.vertical)
                
                HStack {
                    if viewModel.channels.count == 0 {
                        ProgressView()
                    } else {
                        Text("Select a channel")
                            .font(.subheadline)
                        
                        Spacer()
                    }
                }
                
                ShareChannelsView(viewModel: viewModel)
            }
            .padding()
        }
        .allowsHitTesting(!viewModel.loading)
    }
}

It’s a standard SwiftUI implementation for the UI shown above. We are also using few helper views here:

struct TopView: View {
    
    @ObservedObject var viewModel: DemoShareViewModel
    
    var body: some View {
        HStack {
            Button(action: {
                viewModel.dismissShareSheet()
            }, label: {
                Text("Cancel")
            })
            Spacer()
            Button(action: {
                Task {
                    do {
                        try await viewModel.sendMessage()
                    } catch {
                        viewModel.dismissShareSheet()
                    }
                }
            }, label: {
                if viewModel.loading {
                    ProgressView()
                } else {
                    Text("Send")
                        .bold()
                }
            })
            .disabled(viewModel.selectedChannel == nil)

        }
        .padding(.vertical, 8)
        .padding(.horizontal, 16)
        .frame(height: 44)
        .overlay(
            Text("Send to")
                .bold()
        )
    }
}

struct ImageToShareView: View {
    
    private let imageHeight: CGFloat = 180
    
    var image: UIImage
    var contentMode: ContentMode = .fit
    
    var body: some View {
        Image(uiImage: image)
            .resizable()
            .aspectRatio(contentMode: contentMode)
            .frame(height: imageHeight)
            .cornerRadius(8)
    }
    
}

struct ShareChannelsView: View {
    
    @ObservedObject var viewModel: DemoShareViewModel
    
    var body: some View {
        ScrollView {
            LazyVStack(alignment: .leading, spacing: 8) {
                ForEach(viewModel.channels) { channel in
                    Button {
                        viewModel.channelTapped(channel)
                    } label: {
                        HStack {
                            ChatChannelAvatarView.asView(
                                (channel: channel, currentUserId: viewModel.currentUserId)
                            )
                            .frame(width: 64, height: 64)
                            
                            Text(channel.name ?? channel.id)
                                .bold()
                                .lineLimit(1)
                                .foregroundColor(.primary)
                            Spacer()
                            if viewModel.selectedChannel == channel {
                                Image(systemName: "checkmark")
                            }
                        }
                    }
                }
            }
        }
    }
    
}

extension ChatChannel: Identifiable {
    public var id: String {
        return cid.rawValue
    }
}

Next, let’s see the implementation of the DemoShareViewModel, that handles the loading of the selected image and sends it to Stream’s chat.

import Combine
import CoreServices
import UIKit
import StreamChat
import Social

@MainActor
class DemoShareViewModel: ObservableObject, ChatChannelControllerDelegate {
    
    private let chatClient: ChatClient
    private let userCredentials: UserCredentials
    private var channelListController: ChatChannelListController?
    private var channelController: ChatChannelController?
    private var messageId: MessageId?
    private var extensionContext: NSExtensionContext?
    private var imageURLs = [URL]() {
        didSet {
            images = imageURLs.compactMap { url in
                if let data = try? Data(contentsOf: url),
                   let image = UIImage(data: data) {
                    return image
                }
                return nil
            }
        }
    }
    
    var currentUserId: UserId? {
        chatClient.currentUserId
    }
    
    @Published var channels = LazyCachedMapCollection<ChatChannel>()
    @Published var text = ""
    @Published var images = [UIImage]()
    @Published var selectedChannel: ChatChannel?
    @Published var loading = false
    
    init(
        userCredentials: UserCredentials,
        extensionContext: NSExtensionContext?
    ) {
        var config = ChatClientConfig(apiKey: .init(apiKeyString))
        config.isClientInActiveMode = true
        config.applicationGroupIdentifier = applicationGroupIdentifier
        
        let client = ChatClient(config: config)
        client.setToken(token: Token(stringLiteral: userCredentials.token.rawValue))

        self.chatClient = client
        self.userCredentials = userCredentials
        self.extensionContext = extensionContext
        self.loadChannels()
        self.loadImages()
    }
    
    func sendMessage() async throws {
        guard let cid = selectedChannel?.cid else {
            throw ClientError.Unexpected("No channel selected")
        }
        self.channelController = chatClient.channelController(for: cid)
        guard let channelController = channelController else {
            throw ClientError.Unexpected("Can't upload attachment")
        }
        channelController.delegate = self
        loading = true        
        try await channelController.synchronize()
        let remoteUrls = await withThrowingTaskGroup(of: URL.self) { taskGroup in
            for url in imageURLs {
                taskGroup.addTask {
                    let uploaded = try await channelController.uploadAttachment(
                        localFileURL: url,
                        type: .image
                    )
                    return uploaded.remoteURL
                }
            }
            
            var results = [URL]()
            while let result = await taskGroup.nextResult() {
                if let url = try? result.get() {
                    results.append(url)
                }
            }
            return results
        }
        
        var attachmentPayloads = [AnyAttachmentPayload]()
        for remoteUrl in remoteUrls {
            let attachment = ImageAttachmentPayload(title: nil, imageRemoteURL: remoteUrl)
            attachmentPayloads.append(AnyAttachmentPayload(payload: attachment))
        }
        
        messageId = try await channelController.createNewMessage(
            text: text,
            attachments: attachmentPayloads
        )
    }
    
    func channelTapped(_ channel: ChatChannel) {
        if selectedChannel == channel {
            selectedChannel = nil
        } else {
            selectedChannel = channel
        }
    }
    
    
    func dismissShareSheet() {
        loading = false
        self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
    }
    
    nonisolated func channelController(
        _ channelController: ChatChannelController,
        didUpdateMessages changes: [ListChange<ChatMessage>]
    ) {
        Task {
            await MainActor.run {
                for change in changes {
                    if case .update(let item, _) = change {
                        if messageId == item.id, item.localState == nil {
                            dismissShareSheet()
                            return
                        }
                    }
                }
            }
        }
    }
    
    // MARK: - private
    
    private func loadItem(from itemProvider: NSItemProvider, type: String) async throws -> NSSecureCoding {
        return try await withCheckedThrowingContinuation { continuation in
            itemProvider.loadItem(forTypeIdentifier: type) { item, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else if let item = item {
                    continuation.resume(returning: item)
                } else {
                    continuation.resume(throwing: ClientError.Unknown())
                }
            }
        }
    }
    
    private func loadImages() {
        Task {
            let inputItems = extensionContext?.inputItems
            var urls = [URL]()
            for inputItem in (inputItems ?? []) {
                if let extensionItem = inputItem as? NSExtensionItem {
                    for itemProvider in (extensionItem.attachments ?? []) {
                        if itemProvider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
                            let item = try await loadItem(from: itemProvider, type: kUTTypeImage as String)
                            if let item = item as? URL {
                                urls.append(item)
                            }
                        }
                    }
                }
            }
            self.imageURLs = urls
        }
    }
    
    private func loadChannels() {
        Task {
            try await chatClient.connect(
                userInfo: userCredentials.userInfo,
                token: userCredentials.token
            )
            let channelListQuery: ChannelListQuery = .init(
                filter: .containMembers(userIds: [chatClient.currentUserId ?? ""])
            )
            self.channelListController = chatClient.channelListController(query: channelListQuery)
            channelListController?.synchronize { [weak self] error in
                guard let self, error == nil else { return }
                channels = channelListController?.channels ?? []
            }
        }
    }
}

When we create the view model, we are connecting the user and creating and synchronizing a channel list controller. We need this controller in order to fetch the latest channels and present them in a list to the user for selection.

Note that in this example, for simplicity, we are not paginating through the channels. You can do that by using the loadPreviousMessages from the ChatChannelController.

The other important bits of this code are the loadImages method, that loads the images provided from the extension context, as well as the methods for uploading the attachments and sending the message to Stream chat.

We also implement the channelController.didUpdateMessages delegate method, to dismiss the extension when the message is successfully sent, in order to avoid inconsistent local state.

To make the implementation more readable and robust, we are also wrapping the completion handlers from the ChatClient and ChatChannelController in async/await syntax.

import Foundation
import StreamChat

extension ChatClient {
    func connect(userInfo: UserInfo, token: Token) async throws {
        return try await withCheckedThrowingContinuation { continuation in
            connectUser(userInfo: userInfo, token: token) { error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: ())
                }
            }
        }
    }
}

extension ChatChannelController {
    func synchronize() async throws {
        return try await withCheckedThrowingContinuation { continuation in
            self.synchronize { error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: ())
                }
            }
        }
    }
    
    func uploadAttachment(
        localFileURL: URL,
        type: AttachmentType
    ) async throws -> UploadedAttachment {
        return try await withCheckedThrowingContinuation { continuation in
            uploadAttachment(localFileURL: localFileURL, type: type) { result in
                switch result {
                case .success(let uploaded):
                    continuation.resume(returning: uploaded)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }
    
    @discardableResult
    func createNewMessage(text: String, attachments: [AnyAttachmentPayload]) async throws -> MessageId {
        return try await withCheckedThrowingContinuation { continuation in
            createNewMessage(
                text: text,
                attachments: attachments
            ) { result in
                switch result {
                case .success(let messageId):
                    continuation.resume(returning: messageId)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

With that, image attachments can successfully be sent to Stream chat from a Share extension.

Donating intents

If you want to have your extension appear in the suggestions of the share sheet, you should donate a INSendMessage intent to SiriKit, when you send a message.

You can find more details about this here.

© Getstream.io, Inc. All Rights Reserved.