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>
12 files changed,
+401
−65
ios/LChat/LChat.entitlements+4 −0ios/LChat/ShareInbox.swift+31 −0ios/LChat/Views/ComposerField.swift+23 −0ios/LChat/Views/NativeChatScreen.swift+56 −31ios/LChat/Views/ThreadListView.swift+37 −0ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+7 −2ios/LChatCore/Sources/LChatCore/JumpWave.swift+53 −0ios/LChatMac/NativeFeedView.swift+33 −32ios/LChatShare/Info.plist+41 −0ios/LChatShare/LChatShare.entitlements+10 −0ios/LChatShare/ShareViewController.swift+79 −0ios/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. |