eleven / messenger Public

Share-sheet receiver, paste-to-upload, gentler side-to-side wave, blank-open fix (#297)

* core: the jump wave calms down — one minion in a crowd, not a performance

Shared JumpWave modifier (both platforms, one set of keyframes): the
wag drops from ±5° to ±2.2° settling through ±1°, the tiptoe from
1.04 to 1.015, one fewer beat. Replaces the two inline animators.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac,ios: side-to-side wave; blank-open fix; paste image to upload

- The wave sways side to side now (rotation + x-offset following),
  dropping the vertical tiptoe that read as up-down — both platforms
  via the shared JumpWave.
- Blank chat from a search tap: the store's instant-open could publish
  a page that filters to NOTHING renderable (rows stored before the
  app-event projection existed) while still marking the feed ready —
  a revealed empty feed. Ready now requires something actually
  rendered; otherwise the network fold (which projects) populates.
- Paste image to upload: iOS's composer text view accepts image
  pastes (stages as an upload; text pastes untouched; mixed pastes do
  both) and the Mac composer takes ⌘V'd images and copied files via
  onPasteCommand — like the web composer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: Eleven joins the system share sheet

A share extension (LChatShare) receives images and a video from
Photos/anywhere into an app-group inbox — no keys, no network, just a
byte ferry, so the E2E posture is unchanged. On next activation the
app notices the inbox, asks which chat ("Send photo to…", a native
picker), and stages the files into that chat's composer, where they
encrypt + upload exactly like picked photos. Inbox copies are deleted
once staged; Cancel burns them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 7ccd6234e126c8d88fa61122e420188ad4e5eb9c parent ad31b89
12 files changed, +401 −65
  • ios/LChat/LChat.entitlements +4 −0
  • ios/LChat/ShareInbox.swift +31 −0
  • ios/LChat/Views/ComposerField.swift +23 −0
  • ios/LChat/Views/NativeChatScreen.swift +56 −31
  • ios/LChat/Views/ThreadListView.swift +37 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +7 −2
  • ios/LChatCore/Sources/LChatCore/JumpWave.swift +53 −0
  • ios/LChatMac/NativeFeedView.swift +33 −32
  • ios/LChatShare/Info.plist +41 −0
  • ios/LChatShare/LChatShare.entitlements +10 −0
  • ios/LChatShare/ShareViewController.swift +79 −0
  • ios/project.yml +27 −0
diff --git a/ios/LChat/LChat.entitlements b/ios/LChat/LChat.entitlements
index 81e6b87..bd54bab 100644
--- a/ios/LChat/LChat.entitlements
+++ b/ios/LChat/LChat.entitlements
@@ -16,6 +16,10 @@
<array>
<string>applinks:*.luan.chat</string>
</array>
+ <key>com.apple.security.application-groups</key>
+ <array>
+ <string>group.com.elevenmessenger.messenger</string>
+ </array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.elevenmessenger.messenger.shared</string>
diff --git a/ios/LChat/ShareInbox.swift b/ios/LChat/ShareInbox.swift
new file mode 100644
index 0000000..132e120
--- /dev/null
+++ b/ios/LChat/ShareInbox.swift
@@ -0,0 +1,31 @@
+import Foundation
+
+// 🤖 The app side of the share extension's handoff: shared images/videos wait
+// in the app-group inbox until the app activates, shows the chat picker, and
+// stages them into the chosen chat's composer (where they encrypt + upload
+// exactly like a picked photo). Files are deleted once staged.
+enum ShareInbox {
+ static let appGroup = "group.com.elevenmessenger.messenger"
+
+ static var inboxURL: URL? {
+ FileManager.default
+ .containerURL(forSecurityApplicationGroupIdentifier: appGroup)?
+ .appendingPathComponent("share-inbox", isDirectory: true)
+ }
+
+ /// Waiting shared files (oldest first), empty when there's nothing.
+ static func pending() -> [URL] {
+ guard let dir = inboxURL,
+ let files = try? FileManager.default.contentsOfDirectory(
+ at: dir, includingPropertiesForKeys: [.creationDateKey]) else { return [] }
+ return files.sorted { a, b in
+ let da = (try? a.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? .distantPast
+ let db = (try? b.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? .distantPast
+ return da < db
+ }
+ }
+
+ static func clear(_ urls: [URL]) {
+ for url in urls { try? FileManager.default.removeItem(at: url) }
+ }
+}
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift
index 4c65b27..913231c 100644
--- a/ios/LChat/Views/ComposerField.swift
+++ b/ios/LChat/Views/ComposerField.swift
@@ -20,11 +20,14 @@ struct ComposerField: UIViewRepresentable {
var placeholder: String = ""
var sendOnReturn: Bool
var onSend: () -> Void
+ // A pasted image (the composer's "paste to upload") — data is JPEG.
+ var onPasteImage: ((Data) -> Void)? = nil
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> GrowingTextView {
let tv = GrowingTextView()
+ tv.onPasteImage = onPasteImage
tv.delegate = context.coordinator
tv.font = .preferredFont(forTextStyle: .body)
tv.backgroundColor = .clear
@@ -101,6 +104,26 @@ struct ComposerField: UIViewRepresentable {
final class GrowingTextView: UITextView {
let placeholderLabel = UILabel()
private(set) var shiftHeld = false
+ // Pasting an image stages it as an upload instead of inserting nothing —
+ // text pastes are untouched, and a mixed clipboard does both.
+ var onPasteImage: ((Data) -> Void)?
+
+ override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
+ if action == #selector(paste(_:)), onPasteImage != nil,
+ UIPasteboard.general.hasImages { return true }
+ return super.canPerformAction(action, withSender: sender)
+ }
+
+ override func paste(_ sender: Any?) {
+ let pb = UIPasteboard.general
+ if pb.hasImages, let onPasteImage {
+ for image in pb.images ?? [] {
+ if let data = image.jpegData(compressionQuality: 0.92) { onPasteImage(data) }
+ }
+ if !pb.hasStrings { return } // image-only paste fully consumed
+ }
+ super.paste(sender)
+ }
private var lineHeight: CGFloat { (font ?? .preferredFont(forTextStyle: .body)).lineHeight }
var capHeight: CGFloat { ceil(lineHeight * 6) }
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 7296eff..aa72a89 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -54,6 +54,8 @@ struct NativeChatScreen: View {
// A search hit's "open the chat ON this message" request, set by the
// chat list before pushing this screen.
static var pendingJump: (thread: String, message: Int64)?
+ // Shared files (share-sheet handoff) to stage into this chat's composer.
+ static var pendingShare: (thread: String, files: [URL])?
@State private var draft = ""
@State private var caret = 0
@@ -184,6 +186,22 @@ struct NativeChatScreen: View {
}
.onAppear {
model.start()
+ // Share-sheet handoff: stage each shared file as an upload
+ // (encrypt + upload run exactly like a picked photo), then
+ // burn the inbox copies.
+ if let share = Self.pendingShare, share.thread == thread.id {
+ Self.pendingShare = nil
+ for url in share.files {
+ guard let data = try? Data(contentsOf: url) else { continue }
+ let ext = url.pathExtension.lowercased()
+ if ["mp4", "mov", "m4v"].contains(ext) {
+ stageVideoData(data, name: url.lastPathComponent)
+ } else {
+ stageImageData(data, name: url.lastPathComponent)
+ }
+ }
+ ShareInbox.clear(share.files)
+ }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
withAnimation(.spring(response: 0.42, dampingFraction: 0.45)) { avatarPop = true }
}
@@ -482,40 +500,12 @@ struct NativeChatScreen: View {
.simultaneousGesture(rowSwipe(m))
.clipped()
// The jump wave, applied AFTER .clipped() so the wag never
- // beheads at the row edge. Feet planted, tiptoes, eager
- // side-to-side — the Mac feed's Minion, same keyframes.
- .keyframeAnimator(initialValue: DancePose(), trigger: danceTick) { view, pose in
- let on = m.id == danceID
- view
- .rotationEffect(.degrees(on ? pose.angle : 0), anchor: .bottom)
- .scaleEffect(x: 1, y: on ? pose.scale : 1, anchor: .bottom)
- } keyframes: { _ in
- KeyframeTrack(\.angle) {
- CubicKeyframe(0, duration: 0.08)
- CubicKeyframe(5, duration: 0.11)
- CubicKeyframe(-5, duration: 0.12)
- CubicKeyframe(5, duration: 0.12)
- CubicKeyframe(-5, duration: 0.12)
- CubicKeyframe(4, duration: 0.12)
- CubicKeyframe(-3, duration: 0.12)
- CubicKeyframe(0, duration: 0.18)
- }
- KeyframeTrack(\.scale) {
- CubicKeyframe(1.04, duration: 0.14)
- CubicKeyframe(1.04, duration: 0.63)
- CubicKeyframe(1.0, duration: 0.2)
- }
- }
+ // beheads at the row edge (shared keyframes: JumpWave in core).
+ .jumpWave(on: m.id == danceID, tick: danceTick)
}
}
}
- // The wave's animatable channels (wag + tiptoe stretch).
- private struct DancePose {
- var angle = 0.0
- var scale = 1.0
- }
-
// Scroll to a message and wave it — quote-taps and search hits share this.
private func jumpAndWave(_ id: Int64, proxy: ScrollViewProxy) {
withAnimation { proxy.scrollTo(id, anchor: .center) }
@@ -641,7 +631,8 @@ struct NativeChatScreen: View {
// sendOnReturn: false — touch Enter newlines, the send button
// sends (the shouldSend rule).
ComposerField(text: $draft, focused: $composerFocused, caret: $caret,
- placeholder: "Message", sendOnReturn: false, onSend: sendDraft)
+ placeholder: "Message", sendOnReturn: false, onSend: sendDraft,
+ onPasteImage: { stageImageData($0) })
.padding(.leading, 14)
.padding(.vertical, 8)
.onChange(of: draft) { _, _ in
@@ -853,6 +844,40 @@ struct NativeChatScreen: View {
}
}
+ // Stage raw image bytes (paste / share-sheet handoff) as an upload.
+ private func stageImageData(_ data: Data, name: String = "pasted.jpg") {
+ let entry = StagedUpload(name: name)
+ staged.append(entry)
+ let onProgress: @MainActor (Double) -> Void = { p in
+ if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p }
+ }
+ Task {
+ let att = await model.uploadImage(data, name: name, onProgress: onProgress)
+ if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) {
+ staged[i].attachment = att
+ } else {
+ staged.removeAll { $0.id == entry.id }
+ }
+ }
+ }
+
+ private func stageVideoData(_ data: Data, name: String) {
+ let entry = StagedUpload(name: name)
+ staged.append(entry)
+ let onProgress: @MainActor (Double) -> Void = { p in
+ if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p }
+ }
+ Task {
+ let att = await model.uploadVideo(data, name: name, mimeType: "video/mp4",
+ onProgress: onProgress)
+ if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) {
+ staged[i].attachment = att
+ } else {
+ staged.removeAll { $0.id == entry.id }
+ }
+ }
+ }
+
private func stageFile(_ url: URL) {
guard url.startAccessingSecurityScopedResource() else { return }
defer { url.stopAccessingSecurityScopedResource() }
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index 1325d4b..0683700 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -16,6 +16,9 @@ struct ThreadListView: View {
var lockScroll: Bool = false
@State private var showNew = false
@State private var search = ""
+ @Environment(\.scenePhase) private var scenePhase
+ // Files waiting in the share-extension inbox (app-group container).
+ @State private var shareFiles: [URL] = []
// Mirrors renderThreadList in web/static/chat.js: a search reveals everything
// that matches (so a closed chat can be found and reopened), while the default
@@ -93,6 +96,40 @@ struct ThreadListView: View {
.sheet(isPresented: $showSettings) {
ServerSettingsSheet(store: store)
}
+ // Shared from the system share sheet: pick which chat gets the files.
+ .sheet(isPresented: Binding(get: { !shareFiles.isEmpty },
+ set: { if !$0 { shareFiles = [] } })) {
+ NavigationStack {
+ List(store.threads.filter { !$0.hidden && !$0.isApp }) { t in
+ Button {
+ NativeChatScreen.pendingShare = (t.id, shareFiles)
+ shareFiles = []
+ path.append(t.id)
+ } label: {
+ HStack(spacing: 10) {
+ Avatar(name: t.title, size: 34, source: t.avatar, glyph: t.glyph)
+ Text(t.title).font(.body.weight(.medium)).lineLimit(1)
+ if let badge = t.badge { AgentBadge(badge) }
+ }
+ }
+ .foregroundStyle(.primary)
+ }
+ .navigationTitle("Send \(shareFiles.count == 1 ? "photo" : "\(shareFiles.count) items") to…")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ ShareInbox.clear(shareFiles)
+ shareFiles = []
+ }
+ }
+ }
+ }
+ }
+ .onChange(of: scenePhase) { _, phase in
+ if phase == .active { shareFiles = ShareInbox.pending() }
+ }
+ .onAppear { shareFiles = ShareInbox.pending() }
.navigationDestination(for: String.self) { id in
ThreadDestination(store: store, id: id)
}
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index e17cba7..8c1a379 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -428,8 +428,13 @@ public final class ChatFeedModel: ObservableObject {
let cached = await MessageStore.shared.page(instance: instance.id, thread: threadID)
if !cached.isEmpty, messages.isEmpty {
publishStored(cached)
- ready = true
- lastOlderLoad = Date()
+ // Only mark ready if something actually rendered: rows stored
+ // before a projection existed (app-thread events) can filter
+ // to NOTHING, and ready-but-empty is a blank chat.
+ if !messages.isEmpty {
+ ready = true
+ lastOlderLoad = Date()
+ }
}
}
let rows = await api.messages(thread: threadID, after: 0)
diff --git a/ios/LChatCore/Sources/LChatCore/JumpWave.swift b/ios/LChatCore/Sources/LChatCore/JumpWave.swift
new file mode 100644
index 0000000..a74c174
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/JumpWave.swift
@@ -0,0 +1,53 @@
+import SwiftUI
+
+// 🤖 The jump wave, shared by both platforms: the message a quote-tap or a
+// search hit landed on asks for attention like ONE minion in a crowd of
+// minions — a small tiptoe and a gentle wag, not a performance. Every row
+// runs the animator on the shared tick; non-targets wave by zero.
+public struct JumpWave: ViewModifier {
+ let on: Bool
+ let tick: Int
+
+ public init(on: Bool, tick: Int) {
+ self.on = on
+ self.tick = tick
+ }
+
+ struct Pose {
+ var angle = 0.0
+ var sway = 0.0
+ }
+
+ public func body(content: Content) -> some View {
+ content
+ .keyframeAnimator(initialValue: Pose(), trigger: tick) { view, pose in
+ view
+ .rotationEffect(.degrees(on ? pose.angle : 0), anchor: .bottom)
+ .offset(x: on ? pose.sway : 0)
+ } keyframes: { _ in
+ KeyframeTrack(\.angle) { // a gentle side-to-side wag…
+ CubicKeyframe(0, duration: 0.10)
+ CubicKeyframe(2.0, duration: 0.14)
+ CubicKeyframe(-2.0, duration: 0.16)
+ CubicKeyframe(1.4, duration: 0.16)
+ CubicKeyframe(-0.8, duration: 0.16)
+ CubicKeyframe(0, duration: 0.22)
+ }
+ KeyframeTrack(\.sway) { // …with the body following
+ CubicKeyframe(0, duration: 0.10)
+ CubicKeyframe(3, duration: 0.14)
+ CubicKeyframe(-3, duration: 0.16)
+ CubicKeyframe(2, duration: 0.16)
+ CubicKeyframe(-1, duration: 0.16)
+ CubicKeyframe(0, duration: 0.22)
+ }
+ }
+ }
+}
+
+extension View {
+ /// Wave when `on` (this row is the jump target) and `tick` bumps.
+ public func jumpWave(on: Bool, tick: Int) -> some View {
+ modifier(JumpWave(on: on, tick: tick))
+ }
+}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 61f2f41..8115931 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -555,38 +555,7 @@ struct NativeFeedView: View {
// (its avatar sits at 14pt).
.padding(EdgeInsets(top: startsRun ? 8 : 2, leading: 14,
bottom: 2, trailing: 10))
- // The jumped-to bubble waves for attention — feet planted, up on its
- // tiptoes, an eager side-to-side wag held for a few beats before it
- // settles (a Minion in a sea of minions, not a jump). Every row runs
- // the animator on the shared tick, but non-targets wave by zero.
- .keyframeAnimator(initialValue: DancePose(), trigger: danceTick) { view, pose in
- let on = m.id == danceID
- view
- .rotationEffect(.degrees(on ? pose.angle : 0), anchor: .bottom)
- .scaleEffect(x: 1, y: on ? pose.scale : 1, anchor: .bottom)
- } keyframes: { _ in
- KeyframeTrack(\.angle) { // the wave: eager, then settles
- CubicKeyframe(0, duration: 0.08)
- CubicKeyframe(5, duration: 0.11)
- CubicKeyframe(-5, duration: 0.12)
- CubicKeyframe(5, duration: 0.12)
- CubicKeyframe(-5, duration: 0.12)
- CubicKeyframe(4, duration: 0.12)
- CubicKeyframe(-3, duration: 0.12)
- CubicKeyframe(0, duration: 0.18)
- }
- KeyframeTrack(\.scale) { // on tiptoes for the whole wave
- CubicKeyframe(1.04, duration: 0.14)
- CubicKeyframe(1.04, duration: 0.63)
- CubicKeyframe(1.0, duration: 0.2)
- }
- }
- }
-
- // The wave's animatable channels (wag + tiptoe stretch).
- private struct DancePose {
- var angle = 0.0
- var scale = 1.0
+ .jumpWave(on: m.id == danceID, tick: danceTick)
}
// The native composer — the first native SEND path: the message is
@@ -736,6 +705,11 @@ struct NativeFeedView: View {
.buttonStyle(.plain)
.padding(.bottom, 4)
TextField("Message", text: $draft, axis: .vertical)
+ // Paste an image (or a copied file) straight onto the draft -
+ // it stages as an upload, like the web composer's paste.
+ .onPasteCommand(of: [.png, .jpeg, .tiff, .image, .fileURL]) { providers in
+ handlePaste(providers)
+ }
.textFieldStyle(.plain)
.font(.system(size: 16)) // the feed's reading size, like the web composer
.lineLimit(1...9) // grows to ~9 lines, then scrolls within itself
@@ -950,10 +924,37 @@ struct NativeFeedView: View {
}
}
+ // Pasted content: file URLs route through stage(url); raw image data
+ // stages as a PNG (like the web composer's paste-to-upload).
+ private func handlePaste(_ providers: [NSItemProvider]) {
+ for provider in providers {
+ if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
+ _ = provider.loadObject(ofClass: URL.self) { url, _ in
+ if let url { DispatchQueue.main.async { stage(url) } }
+ }
+ continue
+ }
+ let types: [UTType] = [.png, .jpeg, .tiff, .image]
+ guard let type = types.first(where: {
+ provider.hasItemConformingToTypeIdentifier($0.identifier)
+ }) else { continue }
+ provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in
+ guard let data else { return }
+ DispatchQueue.main.async {
+ stage(data: data, name: "pasted.png", type: .png)
+ }
+ }
+ }
+ }
+
private func stage(_ url: URL) {
guard let data = try? Data(contentsOf: url) else { return }
let name = url.lastPathComponent
let type = UTType(filenameExtension: url.pathExtension)
+ stage(data: data, name: name, type: type)
+ }
+
+ private func stage(data: Data, name: String, type: UTType?) {
let isImage = type?.conforms(to: .image) ?? false
let isVideo = type?.conforms(to: .movie) ?? false
guard isImage || isVideo || fileAllowed(name) else {
diff --git a/ios/LChatShare/Info.plist b/ios/LChatShare/Info.plist
new file mode 100644
index 0000000..6f2b658
--- /dev/null
+++ b/ios/LChatShare/Info.plist
@@ -0,0 +1,41 @@
+<?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>CFBundleDevelopmentRegion</key>
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
+ <key>CFBundleDisplayName</key>
+ <string>Eleven</string>
+ <key>CFBundleExecutable</key>
+ <string>$(EXECUTABLE_NAME)</string>
+ <key>CFBundleIdentifier</key>
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>$(PRODUCT_NAME)</string>
+ <key>CFBundlePackageType</key>
+ <string>XPC!</string>
+ <key>CFBundleShortVersionString</key>
+ <string>$(MARKETING_VERSION)</string>
+ <key>CFBundleVersion</key>
+ <string>$(CURRENT_PROJECT_VERSION)</string>
+ <key>NSExtension</key>
+ <dict>
+ <key>NSExtensionAttributes</key>
+ <dict>
+ <key>NSExtensionActivationRule</key>
+ <dict>
+ <key>NSExtensionActivationSupportsImageWithMaxCount</key>
+ <integer>6</integer>
+ <key>NSExtensionActivationSupportsMovieWithMaxCount</key>
+ <integer>1</integer>
+ </dict>
+ </dict>
+ <key>NSExtensionPointIdentifier</key>
+ <string>com.apple.share-services</string>
+ <key>NSExtensionPrincipalClass</key>
+ <string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
+ </dict>
+</dict>
+</plist>
diff --git a/ios/LChatShare/LChatShare.entitlements b/ios/LChatShare/LChatShare.entitlements
new file mode 100644
index 0000000..86255c6
--- /dev/null
+++ b/ios/LChatShare/LChatShare.entitlements
@@ -0,0 +1,10 @@
+<?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>com.apple.security.application-groups</key>
+ <array>
+ <string>group.com.elevenmessenger.messenger</string>
+ </array>
+</dict>
+</plist>
diff --git a/ios/LChatShare/ShareViewController.swift b/ios/LChatShare/ShareViewController.swift
new file mode 100644
index 0000000..ede106a
--- /dev/null
+++ b/ios/LChatShare/ShareViewController.swift
@@ -0,0 +1,79 @@
+import UIKit
+import UniformTypeIdentifiers
+
+// 🤖 The share-sheet receiver: images (and a video) shared from Photos or
+// anywhere land in an app-group inbox; the main app notices on next
+// activation and asks which chat to send them to. The extension itself never
+// touches keys or the network — it only ferries bytes across the app-group
+// boundary, so the E2E posture is unchanged (encryption happens in the app,
+// exactly as if the image had been picked from the composer).
+final class ShareViewController: UIViewController {
+ static let appGroup = "group.com.elevenmessenger.messenger"
+
+ private let label = UILabel()
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = .systemBackground
+ label.text = "Saving…"
+ label.font = .preferredFont(forTextStyle: .headline)
+ label.textAlignment = .center
+ label.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(label)
+ NSLayoutConstraint.activate([
+ label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+ label.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 20),
+ ])
+ Task { await ingest() }
+ }
+
+ private func ingest() async {
+ guard let inbox = FileManager.default
+ .containerURL(forSecurityApplicationGroupIdentifier: Self.appGroup)?
+ .appendingPathComponent("share-inbox", isDirectory: true) else {
+ finish("Couldn't save — open Eleven and try attaching instead.")
+ return
+ }
+ try? FileManager.default.createDirectory(at: inbox, withIntermediateDirectories: true)
+ var saved = 0
+ for item in extensionContext?.inputItems.compactMap({ $0 as? NSExtensionItem }) ?? [] {
+ for provider in item.attachments ?? [] {
+ for type in [UTType.image, UTType.movie] where
+ provider.hasItemConformingToTypeIdentifier(type.identifier) {
+ if await save(provider, as: type, into: inbox) { saved += 1 }
+ break
+ }
+ }
+ }
+ finish(saved > 0 ? "Saved — open Eleven to choose a chat."
+ : "Nothing shareable found.")
+ }
+
+ private func save(_ provider: NSItemProvider, as type: UTType, into inbox: URL) async -> Bool {
+ await withCheckedContinuation { cont in
+ provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, _ in
+ guard let url else {
+ // Some sources hand data, not files (e.g. screenshots).
+ provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in
+ guard let data else { return cont.resume(returning: false) }
+ let ext = type == .movie ? "mp4" : "jpg"
+ let dest = inbox.appendingPathComponent(UUID().uuidString + "." + ext)
+ cont.resume(returning: (try? data.write(to: dest)) != nil)
+ }
+ return
+ }
+ let dest = inbox.appendingPathComponent(
+ UUID().uuidString + "-" + url.lastPathComponent)
+ cont.resume(returning: (try? FileManager.default.copyItem(at: url, to: dest)) != nil)
+ }
+ }
+ }
+
+ private func finish(_ message: String) {
+ label.text = message
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.1) { [weak self] in
+ self?.extensionContext?.completeRequest(returningItems: nil)
+ }
+ }
+}
diff --git a/ios/project.yml b/ios/project.yml
index 2c451c5..18041bf 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -39,6 +39,7 @@ targets:
- path: LChat
dependencies:
- target: LChatNSE
+ - target: LChatShare
- package: LChatCore
settings:
base:
@@ -156,6 +157,32 @@ targets:
NSExtension:
NSExtensionPointIdentifier: com.apple.usernotifications.service
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).NotificationService
+ # Share extension: receives images/videos from the system share sheet into
+ # an app-group inbox; the app picks them up on next activation and asks
+ # which chat to send them to. No keys, no network — just a byte ferry.
+ LChatShare:
+ type: app-extension
+ platform: iOS
+ sources:
+ - path: LChatShare
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: $(LCHAT_APP_BUNDLE_ID).share
+ CODE_SIGN_ENTITLEMENTS: LChatShare/LChatShare.entitlements
+ info:
+ path: LChatShare/Info.plist
+ properties:
+ CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
+ CFBundleShortVersionString: "$(MARKETING_VERSION)"
+ CFBundleDisplayName: Eleven
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.share-services
+ NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
+ NSExtensionAttributes:
+ NSExtensionActivationRule:
+ NSExtensionActivationSupportsImageWithMaxCount: 6
+ NSExtensionActivationSupportsMovieWithMaxCount: 1
+
# End-to-end UI tests (Simulator only). They need a live server and an invite
# link, passed as TEST_RUNNER_INVITE_LINK on the xcodebuild invocation; without
# it the test skips itself. See LChatUITests/JoinAndChatTests.swift.