snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
36
packages/frontend/apps/ios/App/ShareExtension/Info.plist
Normal file
36
packages/frontend/apps/ios/App/ShareExtension/Info.plist
Normal file
@@ -0,0 +1,36 @@
|
||||
<?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>CFBundleDisplayName</key>
|
||||
<string>AFFiNE</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>
|
||||
<string>SUBQUERY(extensionItems, $extensionItem, SUBQUERY($extensionItem.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-EQUALS "public.url" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.adobe.pdf" OR ANY $attachment.registeredTypeIdentifiers UTI-EQUALS "com.apple.property-list").@count > 0).@count > 0</string>
|
||||
<key>NSExtensionJavaScriptPreprocessingFile</key>
|
||||
<string>SafariPageCapture</string>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
var SafariPageCapture = function () {};
|
||||
|
||||
SafariPageCapture.prototype = {
|
||||
run: function (context) {
|
||||
var selection = window.getSelection();
|
||||
context.completionFunction({
|
||||
title: document.title || '',
|
||||
url: document.location.href,
|
||||
selectedText: selection ? selection.toString() : '',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
var ExtensionPreprocessingJS = new SafariPageCapture();
|
||||
@@ -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.app.affine.pro</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,305 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ShareExtensionView: View {
|
||||
@ObservedObject var viewModel: ShareViewModel
|
||||
var onCancel: () -> Void
|
||||
var onSave: () -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
ProgressView("Reading shared content…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
.navigationTitle("AFFiNE")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Not now", action: onCancel)
|
||||
.disabled(viewModel.isSaving)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
if viewModel.isSaving {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button(viewModel.actionTitle, action: onSave)
|
||||
.fontWeight(.semibold)
|
||||
.disabled(!viewModel.canSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Choose a workspace in AFFiNE. This item will stay saved until then.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if viewModel.linkPreviewState != .idle {
|
||||
linkPreviewCard
|
||||
} else {
|
||||
attachmentCard
|
||||
}
|
||||
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
}
|
||||
|
||||
private var titleField: some View {
|
||||
TextField(
|
||||
"Title",
|
||||
text: Binding(
|
||||
get: { viewModel.displayTitle },
|
||||
set: viewModel.updateTitle
|
||||
)
|
||||
)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
}
|
||||
|
||||
private var linkPreviewCard: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
switch viewModel.linkPreviewState {
|
||||
case .loading:
|
||||
previewSkeleton
|
||||
case let .loaded(preview):
|
||||
previewContent(preview)
|
||||
case .failed:
|
||||
fallbackLink
|
||||
case .idle:
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
if let selectedText = viewModel.selectedText, !selectedText.isEmpty {
|
||||
Rectangle()
|
||||
.fill(Color(uiColor: .separator))
|
||||
.frame(height: 1)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Selected text")
|
||||
.font(.footnote.weight(.semibold))
|
||||
Text(selectedText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 14)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func previewContent(_ preview: ShareLinkPreview) -> some View {
|
||||
previewMedia(viewModel.linkPreviewMediaImage)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
if let favicon = viewModel.linkPreviewFaviconImage {
|
||||
Image(uiImage: favicon)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 16, height: 16)
|
||||
.accessibilityHidden(true)
|
||||
} else {
|
||||
Image(systemName: "link")
|
||||
.frame(width: 16, height: 16)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
Text(preview.siteName ?? previewHost)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
titleField
|
||||
.lineLimit(2)
|
||||
if let description = preview.description,
|
||||
!description.isEmpty,
|
||||
description != viewModel.displayTitle
|
||||
{
|
||||
Text(description)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
if let metadata = previewMetadata(preview) {
|
||||
Text(metadata)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
if let transcript = preview.transcript?.previewText {
|
||||
transcriptPreview(transcript)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
|
||||
private func transcriptPreview(_ text: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color(uiColor: .separator))
|
||||
.frame(height: 1)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "waveform")
|
||||
.frame(width: 16, height: 16)
|
||||
.accessibilityHidden(true)
|
||||
Text("Transcript")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(viewModel.selectedText?.isEmpty == false ? 2 : 3)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Transcript preview: \(text)")
|
||||
}
|
||||
|
||||
private var fallbackLink: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "link")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
titleField
|
||||
Text(previewHost)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Preview unavailable")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
|
||||
private var previewSkeleton: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(.quaternary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 180)
|
||||
GeometryReader { geometry in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.quaternary)
|
||||
.frame(width: geometry.size.width * 0.6, height: 12)
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.quaternary)
|
||||
.frame(width: geometry.size.width * 0.9, height: 16)
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.quaternary)
|
||||
.frame(width: geometry.size.width * 0.55, height: 12)
|
||||
}
|
||||
}
|
||||
.frame(height: 56)
|
||||
.padding(14)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Loading link preview")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func previewMedia(_ image: UIImage?) -> some View {
|
||||
if let image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 180)
|
||||
.clipped()
|
||||
.accessibilityHidden(true)
|
||||
} else {
|
||||
mediaPlaceholder
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 180)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private var mediaPlaceholder: some View {
|
||||
ZStack {
|
||||
Color(uiColor: .systemGroupedBackground)
|
||||
Image(systemName: "link")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
private var attachmentCard: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let image = viewModel.previewImage {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(16 / 9, contentMode: .fit)
|
||||
.frame(maxHeight: 180)
|
||||
.clipped()
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: viewModel.previewImage == nil ? "doc.text" : "photo")
|
||||
.font(.title2)
|
||||
.frame(width: 32, height: 32)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
titleField
|
||||
if !viewModel.previewText.isEmpty {
|
||||
Text(viewModel.previewText)
|
||||
.lineLimit(3)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var previewHost: String {
|
||||
guard let value = viewModel.sharedURL, let url = URL(string: value) else { return "Link" }
|
||||
return url.host ?? value
|
||||
}
|
||||
|
||||
private func previewMetadata(_ preview: ShareLinkPreview) -> String? {
|
||||
var values: [String] = []
|
||||
if let author = preview.author?.name { values.append(author) }
|
||||
if let duration = preview.durationSeconds {
|
||||
values.append(String(format: "%d:%02d", Int(duration) / 60, Int(duration) % 60))
|
||||
}
|
||||
return values.prefix(2).isEmpty ? nil : values.prefix(2).joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// ShareViewController.swift
|
||||
// ShareExtension
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
final class ShareViewController: UIViewController {
|
||||
private let viewModel = ShareViewModel()
|
||||
private var hostingController: UIHostingController<ShareExtensionView>?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
|
||||
deinit {
|
||||
loadTask?.cancel()
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
let rootView = ShareExtensionView(
|
||||
viewModel: viewModel,
|
||||
onCancel: { [weak self] in
|
||||
self?.cancel()
|
||||
},
|
||||
onSave: { [weak self] in
|
||||
self?.save()
|
||||
}
|
||||
)
|
||||
let hosting = UIHostingController(rootView: rootView)
|
||||
hostingController = hosting
|
||||
addChild(hosting)
|
||||
view.addSubview(hosting.view)
|
||||
hosting.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
hosting.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
hosting.didMove(toParent: self)
|
||||
|
||||
let viewModel = viewModel
|
||||
let extensionContext = extensionContext
|
||||
loadTask = Task { [weak self] in
|
||||
await viewModel.load(from: extensionContext)
|
||||
self?.loadTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
loadTask?.cancel()
|
||||
loadTask = nil
|
||||
viewModel.discard()
|
||||
extensionContext?.completeRequest(returningItems: nil)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let success = await viewModel.save()
|
||||
guard success else { return }
|
||||
loadTask?.cancel()
|
||||
loadTask = nil
|
||||
_ = await openMainAppIfPossible()
|
||||
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func openMainAppIfPossible() async -> Bool {
|
||||
let url = ShareInboxConstants.openInboxURL
|
||||
let openedByContext = await withCheckedContinuation { continuation in
|
||||
extensionContext?.open(url) { success in
|
||||
continuation.resume(returning: success)
|
||||
} ?? continuation.resume(returning: false)
|
||||
}
|
||||
let opened = openedByContext || openMainAppViaResponderChain(url)
|
||||
#if DEBUG
|
||||
NSLog(
|
||||
"[AFFiNE Share] open url=%@ extensionContext=%@ final=%@",
|
||||
url.absoluteString,
|
||||
openedByContext ? "YES" : "NO",
|
||||
opened ? "YES" : "NO"
|
||||
)
|
||||
#endif
|
||||
return opened
|
||||
}
|
||||
|
||||
private func openMainAppViaResponderChain(_ url: URL) -> Bool {
|
||||
var responder: UIResponder? = self
|
||||
while let current = responder {
|
||||
if let application = current as? UIApplication {
|
||||
application.open(url, options: [:], completionHandler: nil)
|
||||
return true
|
||||
}
|
||||
responder = current.next
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class ShareViewModel: ObservableObject {
|
||||
@Published var title = ""
|
||||
@Published var previewText = ""
|
||||
@Published var previewImage: UIImage?
|
||||
@Published var isLoading = true
|
||||
@Published var isSaving = false
|
||||
@Published var hasSaved = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var linkPreviewState: ShareLinkPreviewState = .idle
|
||||
@Published var linkPreviewMediaImage: UIImage?
|
||||
@Published var linkPreviewFaviconImage: UIImage?
|
||||
|
||||
var actionTitle: String {
|
||||
"Open AFFiNE"
|
||||
}
|
||||
|
||||
var canSave: Bool {
|
||||
!isLoading
|
||||
&& !isSaving
|
||||
&& !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& draft?.content != nil
|
||||
}
|
||||
|
||||
private var previewRoute: SharePreviewRoute = .deferred
|
||||
private var draft: SharePayloadDraft?
|
||||
private var userEditedTitle: String?
|
||||
private var loadGeneration = 0
|
||||
private var previewTask: Task<Void, Never>?
|
||||
private let store: ShareInboxStore
|
||||
private let previewClient: ShareLinkPreviewClient
|
||||
private let buildPayload: ([NSExtensionItem]) async -> SharePayloadDraft
|
||||
|
||||
init(
|
||||
store: ShareInboxStore = .shared,
|
||||
previewClient: ShareLinkPreviewClient = ShareLinkPreviewClient(),
|
||||
buildPayload: @escaping ([NSExtensionItem]) async -> SharePayloadDraft = { items in
|
||||
await SharePayloadBuilder.build(from: items)
|
||||
}
|
||||
) {
|
||||
self.store = store
|
||||
self.previewClient = previewClient
|
||||
self.buildPayload = buildPayload
|
||||
}
|
||||
|
||||
deinit {
|
||||
draft?.discardStagingFiles()
|
||||
}
|
||||
|
||||
var displayTitle: String {
|
||||
ShareInboxSafety.previewTitle(
|
||||
original: title,
|
||||
userEdited: userEditedTitle,
|
||||
serverTitle: linkPreview?.title
|
||||
)
|
||||
}
|
||||
|
||||
var linkPreview: ShareLinkPreview? {
|
||||
guard case let .loaded(preview) = linkPreviewState else { return nil }
|
||||
return preview
|
||||
}
|
||||
|
||||
var sharedURL: String? {
|
||||
draft?.content?.url
|
||||
}
|
||||
|
||||
var selectedText: String? {
|
||||
guard draft?.content?.kind == .url else { return nil }
|
||||
return draft?.content?.text
|
||||
}
|
||||
|
||||
func updateTitle(_ value: String) {
|
||||
userEditedTitle = value
|
||||
title = value
|
||||
}
|
||||
|
||||
func load(from extensionContext: NSExtensionContext?) async {
|
||||
loadGeneration &+= 1
|
||||
let generation = loadGeneration
|
||||
previewTask?.cancel()
|
||||
isLoading = true
|
||||
|
||||
let items = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
|
||||
let built = await buildPayload(items)
|
||||
guard generation == loadGeneration else {
|
||||
built.discardStagingFiles()
|
||||
return
|
||||
}
|
||||
draft?.discardStagingFiles()
|
||||
draft = built
|
||||
userEditedTitle = nil
|
||||
title = built.title
|
||||
previewText = built.previewText
|
||||
errorMessage = built.errorMessage
|
||||
linkPreviewState = .idle
|
||||
linkPreviewMediaImage = nil
|
||||
linkPreviewFaviconImage = nil
|
||||
if let file = built.file {
|
||||
previewImage = UIImage(data: file.thumbnailData)?
|
||||
.preparingThumbnail(of: CGSize(width: 480, height: 480))
|
||||
} else {
|
||||
previewImage = nil
|
||||
}
|
||||
isLoading = false
|
||||
previewRoute = built.content?.url.map { ShareInboxSafety.previewRoute(url: $0, mode: store.workspaceMode()) } ?? .deferred
|
||||
guard built.content?.kind == .url, let url = built.content?.url, previewRoute == .official else {
|
||||
return
|
||||
}
|
||||
linkPreviewState = .loading
|
||||
previewTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let preview = try await previewClient.fetch(url: url)
|
||||
guard !Task.isCancelled, generation == loadGeneration else { return }
|
||||
linkPreviewState = .loaded(preview)
|
||||
async let media = previewClient.fetchImageIfPresent(url: preview.images?.first)
|
||||
async let favicon = previewClient.fetchImageIfPresent(url: preview.favicons?.first)
|
||||
let images = await (media, favicon)
|
||||
guard !Task.isCancelled, generation == loadGeneration else { return }
|
||||
linkPreviewMediaImage = images.0
|
||||
linkPreviewFaviconImage = images.1
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
guard !Task.isCancelled, generation == loadGeneration else { return }
|
||||
linkPreviewState = .failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func discard() {
|
||||
loadGeneration &+= 1
|
||||
previewTask?.cancel()
|
||||
draft?.discardStagingFiles()
|
||||
draft = nil
|
||||
previewImage = nil
|
||||
linkPreviewState = .idle
|
||||
linkPreviewMediaImage = nil
|
||||
linkPreviewFaviconImage = nil
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func save() async -> Bool {
|
||||
guard !isSaving, !hasSaved else { return false }
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
|
||||
let trimmedTitle = ShareInboxSafety.manifestTitle(
|
||||
original: draft?.title ?? title,
|
||||
userEdited: userEditedTitle
|
||||
)
|
||||
guard !trimmedTitle.isEmpty else {
|
||||
errorMessage = "Title is required."
|
||||
return false
|
||||
}
|
||||
guard let draft, let content = draft.content else {
|
||||
errorMessage = draft?.errorMessage ?? "Nothing to share."
|
||||
return false
|
||||
}
|
||||
|
||||
let itemId = UUID().uuidString
|
||||
var attachments: [ShareInboxAttachment] = []
|
||||
var attachmentFiles: [(ShareInboxAttachment, URL)] = []
|
||||
if let file = draft.file {
|
||||
let attachment = ShareInboxAttachment(
|
||||
fileName: file.name,
|
||||
mimeType: file.mimeType,
|
||||
relativePath: "\(itemId)/\(file.name)"
|
||||
)
|
||||
attachments = [attachment]
|
||||
attachmentFiles = [(attachment, file.ownedStagingURL)]
|
||||
}
|
||||
|
||||
let item = ShareInboxItem(
|
||||
id: itemId,
|
||||
title: trimmedTitle,
|
||||
content: content,
|
||||
previewRoute: previewRoute,
|
||||
previewText: draft.previewText,
|
||||
attachments: attachments
|
||||
)
|
||||
|
||||
do {
|
||||
try store.enqueue(item, attachmentFiles: attachmentFiles)
|
||||
hasSaved = true
|
||||
previewTask?.cancel()
|
||||
draft.discardStagingFiles()
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = "Failed to save shared content."
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user