feat: completed input box ui + ux (#12927)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a document picker UI for embedding and selecting AFFiNE
documents, with improved search and recent document functionality.
- Added support for searching and fetching recently updated documents
within a workspace.
- Enhanced error handling for login and metadata validation during
document operations.
  - Added ability to attach documents directly from the input box.

- **Improvements**
- Refined UI animations and layout behaviors for attachment and document
picker components.
- Updated attachment and header views for clearer pluralization and
display logic.
- Improved selection indicators and search experience in the document
picker.
- Enhanced delegate handling for user actions across input and
attachment components.

- **Bug Fixes**
- Fixed progress indicators and error alerts for failed document
operations.

- **Chores**
- Updated project configuration for improved build and signing
processes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Lakr
2025-06-25 17:54:06 +08:00
committed by GitHub
parent 07ec427021
commit 697e0bf9ba
26 changed files with 644 additions and 207 deletions

View File

@@ -3,16 +3,17 @@
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
2E0DD47B57B994A104B25EED /* Pods_AFFiNE.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */; };
5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507513692D1924C600AD60C0 /* RootViewController.swift */; };
5075136E2D1925BC00AD60C0 /* IntelligentsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5075136D2D1925BC00AD60C0 /* IntelligentsPlugin.swift */; };
50802D612D112F8700694021 /* Intelligents in Frameworks */ = {isa = PBXBuildFile; productRef = 50802D602D112F8700694021 /* Intelligents */; };
50802D612D112F8700694021 /* Intelligents in Frameworks */ = {isa = PBXBuildFile; productRef = 50802D602D112F8700694021 /* Intelligents */; settings = {ATTRIBUTES = (Required, ); }; };
50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; };
50A285D82D112A5E000D5A6D /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */; };
50E218302E0BE1A700EA4C6E /* Intelligents in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 50802D602D112F8700694021 /* Intelligents */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
50FF428A2D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */; };
50FF428C2D2E77CC0050AA83 /* AffineViewController+AIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */; };
9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */; };
@@ -36,6 +37,20 @@
E93B276C2CED92B1001409B8 /* NavigationGesturePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E93B276B2CED92B1001409B8 /* NavigationGesturePlugin.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
50E218312E0BE1A700EA4C6E /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
50E218302E0BE1A700EA4C6E /* Intelligents in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
3256F4410D881A03FE77D092 /* Pods-AFFiNE.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AFFiNE.debug.xcconfig"; path = "Pods/Target Support Files/Pods-AFFiNE/Pods-AFFiNE.debug.xcconfig"; sourceTree = "<group>"; };
5039CC962D1D42C700874F32 /* AffineGraphQL */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffineGraphQL; sourceTree = "<group>"; };
@@ -77,8 +92,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
C45499AB2D140B5000E21978 /* NBStore */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = NBStore;
sourceTree = "<group>";
};
@@ -236,6 +249,7 @@
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
50E218312E0BE1A700EA4C6E /* Embed Frameworks */,
);
buildRules = (
);
@@ -326,9 +340,13 @@
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
@@ -514,11 +532,10 @@
baseConfigurationReference = 3256F4410D881A03FE77D092 /* Pods-AFFiNE.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Distribution: TOEVERYTHING PTE. LTD. (73YMMDVT2M)";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = 73YMMDVT2M;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 73YMMDVT2M;
DEVELOPMENT_TEAM = 964G86XT2P;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
@@ -534,8 +551,7 @@
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "AppStore app.affine.pro";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "AppStore app.affine.pro";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
@@ -552,11 +568,10 @@
baseConfigurationReference = E5E5070D1CA1200D4964D91F /* Pods-AFFiNE.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Distribution: TOEVERYTHING PTE. LTD. (73YMMDVT2M)";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = 73YMMDVT2M;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 73YMMDVT2M;
DEVELOPMENT_TEAM = 964G86XT2P;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
@@ -572,8 +587,7 @@
ONLY_ACTIVE_ARCH = NO;
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "AppStore app.affine.pro";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "AppStore app.affine.pro";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;

View File

@@ -13,10 +13,21 @@ extension AFFiNEViewController: IntelligentsButtonDelegate {
IntelligentContext.shared.webView = webView!
button.beginProgress()
IntelligentContext.shared.preparePresent() { _ in
IntelligentContext.shared.preparePresent() { result in
button.stopProgress()
let controller = IntelligentsController()
self.present(controller, animated: true)
switch result {
case .success(let success):
let controller = IntelligentsController()
self.present(controller, animated: true)
case .failure(let failure):
let alert = UIAlertController(
title: "Error",
message: failure.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
}
}
}

View File

@@ -0,0 +1,27 @@
//
// DateTime.swift
// Intelligents
//
// Created by on 6/25/25.
//
import AffineGraphQL
import Apollo
import ApolloAPI
import Foundation
/// A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
extension DateTime {
extension DateTime {
private static let formatter: DateFormatter = {
let fmt = DateFormatter()
fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
fmt.timeZone = TimeZone(identifier: "UTC")
return fmt
}()
var decoded: Date? {
return Self.formatter.date(from: self)
}
}
}

View File

@@ -47,6 +47,17 @@ public class IntelligentContext {
return tempDir.appendingPathComponent("IntelligentContext")
}()
public enum IntelligentError: Error, LocalizedError {
case loginRequired(String)
public var errorDescription: String? {
switch self {
case let .loginRequired(reason):
"Login required: \(reason)"
}
}
}
private init() {}
public func preparePresent(_ completion: @escaping (Result<Void, Error>) -> Void) {
@@ -63,12 +74,28 @@ public class IntelligentContext {
webViewGroup.wait()
webViewMetadata = webViewMetadataResult
if let baseUrlString = webViewMetadataResult[.currentServerBaseUrl] as? String,
let url = URL(string: baseUrlString)
{
QLService.shared.setEndpoint(base: url)
// Check required webView metadata
guard let baseUrlString = webViewMetadataResult[.currentServerBaseUrl] as? String,
!baseUrlString.isEmpty,
let url = URL(string: baseUrlString)
else {
DispatchQueue.main.async {
completion(.failure(IntelligentError.loginRequired("Missing server base URL")))
}
return
}
guard let workspaceId = webViewMetadataResult[.currentWorkspaceId] as? String,
!workspaceId.isEmpty
else {
DispatchQueue.main.async {
completion(.failure(IntelligentError.loginRequired("Missing workspace ID")))
}
return
}
QLService.shared.setEndpoint(base: url)
let gqlGroup = DispatchGroup()
var gqlMetadataResult: [QLMetadataKey: Any] = [:]
gqlGroup.enter()
@@ -79,6 +106,16 @@ public class IntelligentContext {
gqlGroup.wait()
qlMetadata = gqlMetadataResult
// Check required QL metadata
guard let userIdentifier = gqlMetadataResult[.userIdentifierKey] as? String,
!userIdentifier.isEmpty
else {
DispatchQueue.main.async {
completion(.failure(IntelligentError.loginRequired("Missing user identifier")))
}
return
}
dumpMetadataContents()
DispatchQueue.main.async {

View File

@@ -89,4 +89,38 @@ public final class QLService {
}
}
}
public func searchDocuments(
workspaceId: String,
keyword: String,
limit: Int = 20,
completion: @escaping ([IndexerSearchDocsQuery.Data.Workspace.SearchDoc]) -> Void
) {
let input = SearchDocsInput(keyword: keyword, limit: .some(limit))
client.fetch(query: IndexerSearchDocsQuery(id: workspaceId, input: input)) { result in
switch result {
case let .success(graphQLResult):
completion(graphQLResult.data?.workspace.searchDocs ?? [])
case .failure:
completion([])
}
}
}
public func fetchRecentlyUpdatedDocs(
workspaceId: String,
first: Int = 20,
completion: @escaping ([GetRecentlyUpdatedDocsQuery.Data.Workspace.RecentlyUpdatedDocs.Edge.Node]) -> Void
) {
let pagination = PaginationInput(first: .some(first))
client.fetch(query: GetRecentlyUpdatedDocsQuery(workspaceId: workspaceId, pagination: pagination)) { result in
switch result {
case let .success(graphQLResult):
let docs = graphQLResult.data?.workspace.recentlyUpdatedDocs.edges.map(\.node) ?? []
completion(docs)
case .failure:
completion([])
}
}
}
}

View File

@@ -9,11 +9,6 @@ import SnapKit
import Then
import UIKit
protocol AttachmentManagementControllerDelegate: AnyObject {
func deleteFileAttachment(controller: AttachmentManagementController, _ attachment: FileAttachment)
func deleteDocumentAttachment(controller: AttachmentManagementController, _ attachment: DocumentAttachment)
}
class AttachmentManagementController: UINavigationController {
private let _viewController: _AttachmentManagementController
init(delegate: AttachmentManagementControllerDelegate) {
@@ -171,17 +166,20 @@ private class AttachmentCell: UITableViewCell {
let iconView = UIImageView().then {
$0.contentMode = .scaleAspectFit
$0.tintColor = .affineIconPrimary
$0.setContentCompressionResistancePriority(.required, for: .horizontal)
}
let titleLabel = UILabel().then {
$0.textColor = .label
$0.textAlignment = .left
$0.font = .preferredFont(forTextStyle: .body)
$0.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
let deleteButton = UIButton(type: .system).then {
$0.setImage(UIImage(systemName: "xmark"), for: .normal)
$0.tintColor = .affineIconPrimary
$0.setContentCompressionResistancePriority(.required, for: .horizontal)
}
var onDelete: (() -> Void)?

View File

@@ -0,0 +1,13 @@
//
// AttachmentManagementControllerDelegate.swift
// Intelligents
//
// Created by on 6/25/25.
//
import Foundation
protocol AttachmentManagementControllerDelegate: AnyObject {
func deleteFileAttachment(controller: AttachmentManagementController, _ attachment: FileAttachment)
func deleteDocumentAttachment(controller: AttachmentManagementController, _ attachment: DocumentAttachment)
}

View File

@@ -6,6 +6,7 @@
//
import PhotosUI
import SnapKit
import UIKit
import UniformTypeIdentifiers
@@ -37,12 +38,40 @@ extension MainViewController: InputBoxDelegate {
present(documentPicker, animated: true)
}
func inputBoxDidSelectEmbedDocs(_ inputBox: InputBox) {
print(#function, inputBox)
func inputBoxDidSelectEmbedDocs(_: InputBox) {
showDocumentPicker()
}
func inputBoxDidSelectAttachment(_ inputBox: InputBox) {
print(#function, inputBox)
@objc func showDocumentPicker() {
view.endEditing(true)
terminateEditGesture.isEnabled = false
documentPickerView.snp.remakeConstraints { make in
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
make.leading.trailing.equalToSuperview()
make.height.equalTo(300)
}
documentPickerHideDetector.isHidden = false
documentPickerView.setSelectedDocuments(inputBox.viewModel.documentAttachments)
performWithAnimation(duration: 0.75) {
self.view.layoutIfNeeded()
} completion: { _ in
self.documentPickerView.updateDocumentsFromRecentDocs()
self.documentPickerView.searchTextField.becomeFirstResponder()
}
}
@objc func hideDocumentPicker() {
terminateEditGesture.isEnabled = true
documentPickerView.snp.remakeConstraints { make in
make.top.equalTo(view.snp.bottom).offset(200)
make.leading.trailing.equalToSuperview()
make.height.equalTo(300)
}
documentPickerHideDetector.isHidden = true
performWithAnimation(duration: 0.75) {
self.view.layoutIfNeeded()
}
}
func inputBoxDidSend(_ inputBox: InputBox) {
@@ -127,3 +156,30 @@ extension MainViewController: UIDocumentPickerDelegate {
}
}
}
// MARK: - DocumentPickerViewDelegate
extension MainViewController: DocumentPickerViewDelegate {
func documentPickerView(_: DocumentPickerView, didSelectDocument document: DocumentItem) {
// Get current workspace ID
guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String,
!workspaceId.isEmpty
else {
return
}
// Create DocumentAttachment from DocumentItem
let documentAttachment = DocumentAttachment(
title: document.title,
workspaceID: workspaceId,
documentID: document.id,
updatedAt: document.updatedAt
)
// Add to InputBox
inputBox.addDocumentAttachment(documentAttachment)
// Hide document picker
hideDocumentPicker()
}
}

View File

@@ -14,10 +14,23 @@ class MainViewController: UIViewController {
$0.delegate = self
}
lazy var documentPickerHideDetector = UIView().then {
$0.isUserInteractionEnabled = true
$0.isHidden = true
$0.addGestureRecognizer(
UITapGestureRecognizer(target: self, action: #selector(hideDocumentPicker))
)
}
lazy var documentPickerView = DocumentPickerView().then {
$0.delegate = self
}
// MARK: - Properties
private var cancellables = Set<AnyCancellable>()
private let intelligentContext = IntelligentContext.shared
var terminateEditGesture: UITapGestureRecognizer!
// MARK: - Lifecycle
@@ -32,6 +45,8 @@ class MainViewController: UIViewController {
view.addSubview(headerView)
view.addSubview(inputBox)
view.addSubview(documentPickerHideDetector)
view.addSubview(documentPickerView)
headerView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
@@ -42,11 +57,26 @@ class MainViewController: UIViewController {
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
}
documentPickerHideDetector.snp.makeConstraints { make in
make.left.top.right.equalToSuperview()
make.bottom.equalTo(documentPickerView.snp.top)
}
documentPickerView.snp.makeConstraints { make in
make.top.equalTo(view.snp.bottom)
make.leading.trailing.equalToSuperview()
make.height.equalTo(500)
}
view.isUserInteractionEnabled = true
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
view.addGestureRecognizer(terminateEditGesture)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController!.setNavigationBarHidden(true, animated: animated)
documentPickerView.updateDocumentsFromRecentDocs()
DispatchQueue.main.async {
self.inputBox.textView.becomeFirstResponder()
}
@@ -56,4 +86,8 @@ class MainViewController: UIViewController {
super.viewWillDisappear(animated)
navigationController!.setNavigationBarHidden(false, animated: animated)
}
@objc func terminateEditing() {
view.endEditing(true)
}
}

View File

@@ -8,11 +8,12 @@
import UIKit
func performWithAnimation(
duration: TimeInterval = 0.5,
animations: @escaping () -> Void,
completion: @escaping (Bool) -> Void = { _ in }
) {
UIView.animate(
withDuration: 0.5,
withDuration: duration,
delay: 0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 0.8,

View File

@@ -0,0 +1,28 @@
//
// DocumentItem.swift
// Intelligents
//
// Created by on 6/25/25.
//
import Foundation
struct DocumentItem: Hashable {
let id: String
let title: String
let updatedAt: Date?
init(id: String, title: String, updatedAt: Date? = nil) {
self.id = id
self.title = title
self.updatedAt = updatedAt
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: DocumentItem, rhs: DocumentItem) -> Bool {
lhs.id == rhs.id
}
}

View File

@@ -5,31 +5,33 @@
// Created by on 6/24/25.
//
import AffineGraphQL
import SnapKit
import Then
import UIKit
protocol DocumentPickerViewDelegate: AnyObject {
func documentPickerView(_ view: DocumentPickerView, didSelectDocument document: DocumentItem)
func documentPickerView(_ view: DocumentPickerView, didSearchWithText text: String)
}
struct DocumentItem {
let title: String
let icon: UIImage?
}
class DocumentPickerView: UIView {
// MARK: - Properties
weak var delegate: DocumentPickerViewDelegate?
private var documents: [DocumentItem] = []
private var selectedDocumentIds: Set<String> = []
private let updateQueue = DispatchQueue(label: "com.affine.documentpicker.update", qos: .userInitiated)
private var lastSearchKeyword: String = ""
// MARK: - DiffableDataSource
private enum Section {
case main
}
private var dataSource: UITableViewDiffableDataSource<Section, DocumentItem>!
// MARK: - UI Components
private lazy var containerView = UIView().then {
$0.backgroundColor = .white
lazy var containerView = UIView().then {
$0.backgroundColor = .systemBackground
$0.layer.cornerRadius = 10
$0.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
$0.layer.shadowColor = UIColor.black.cgColor
@@ -38,33 +40,37 @@ class DocumentPickerView: UIView {
$0.layer.shadowOpacity = 0.07
}
private lazy var searchContainerView = UIView().then {
$0.backgroundColor = .white
lazy var searchContainerView = UIView().then {
$0.backgroundColor = .systemBackground
$0.layer.cornerRadius = 10
$0.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
$0.layer.borderWidth = 0.5
$0.layer.borderColor = UIColor(hex: 0xE6E6E6)?.cgColor
}
private lazy var searchIconImageView = UIImageView().then {
lazy var searchIconImageView = UIImageView().then {
$0.image = UIImage(systemName: "magnifyingglass")
$0.tintColor = UIColor(hex: 0x141414)
$0.tintColor = .affineIconPrimary
$0.contentMode = .scaleAspectFit
}
private lazy var searchTextField = UITextField().then {
lazy var searchTextField = UITextField().then {
$0.placeholder = "Search documents..."
$0.font = .systemFont(ofSize: 17, weight: .regular)
$0.textColor = UIColor(hex: 0x141414)
$0.textColor = .affineTextPrimary
$0.backgroundColor = .clear
$0.addTarget(self, action: #selector(searchTextChanged), for: .editingChanged)
}
lazy var activityIndicator = UIActivityIndicatorView(style: .medium).then {
$0.hidesWhenStopped = true
$0.color = .affineIconPrimary
}
private lazy var tableView = UITableView().then {
$0.backgroundColor = .white
$0.separatorStyle = .none
$0.delegate = self
$0.dataSource = self
$0.register(DocumentTableViewCell.self, forCellReuseIdentifier: "DocumentCell")
}
@@ -72,7 +78,20 @@ class DocumentPickerView: UIView {
init() {
super.init(frame: .zero)
setupUI()
isUserInteractionEnabled = true
clipsToBounds = false // for shadow
backgroundColor = .systemBackground
addSubview(containerView)
containerView.addSubview(searchContainerView)
containerView.addSubview(tableView)
searchContainerView.addSubview(searchIconImageView)
searchContainerView.addSubview(searchTextField)
searchContainerView.addSubview(activityIndicator)
setupConstraints()
setupDataSource()
}
@available(*, unavailable)
@@ -82,24 +101,9 @@ class DocumentPickerView: UIView {
// MARK: - Setup
private func setupUI() {
backgroundColor = .systemBackground
addSubview(containerView)
containerView.addSubview(searchContainerView)
containerView.addSubview(tableView)
searchContainerView.addSubview(searchIconImageView)
searchContainerView.addSubview(searchTextField)
setupConstraints()
}
private func setupConstraints() {
containerView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.equalTo(393)
make.height.lessThanOrEqualTo(500)
make.edges.equalToSuperview()
}
searchContainerView.snp.makeConstraints { make in
@@ -116,8 +120,14 @@ class DocumentPickerView: UIView {
searchTextField.snp.makeConstraints { make in
make.leading.equalTo(searchIconImageView.snp.trailing).offset(DocumentTableViewCell.spacing)
make.trailing.equalTo(activityIndicator.snp.leading).offset(-DocumentTableViewCell.cellInset)
make.centerY.equalToSuperview()
}
activityIndicator.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-DocumentTableViewCell.cellInset)
make.centerY.equalToSuperview()
make.width.height.equalTo(DocumentTableViewCell.iconSize)
}
tableView.snp.makeConstraints { make in
@@ -126,15 +136,59 @@ class DocumentPickerView: UIView {
}
}
private func setupDataSource() {
dataSource = UITableViewDiffableDataSource<Section, DocumentItem>(tableView: tableView) { [weak self] tableView, indexPath, document in
let cell = tableView.dequeueReusableCell(withIdentifier: "DocumentCell", for: indexPath) as! DocumentTableViewCell
let isSelected = self?.selectedDocumentIds.contains(document.id) ?? false
cell.configure(with: document, isSelected: isSelected)
return cell
}
}
// MARK: - Public Methods
func updateDocuments(_ documents: [DocumentItem]) {
self.documents = documents
tableView.reloadData()
updateQueue.async { [weak self] in
guard let self else { return }
let tableHeight = min(CGFloat(documents.count) * 37.11 + 44, 500)
containerView.snp.updateConstraints { make in
make.height.lessThanOrEqualTo(tableHeight)
DispatchQueue.main.async {
self.documents = documents
var snapshot = NSDiffableDataSourceSnapshot<Section, DocumentItem>()
snapshot.appendSections([.main])
snapshot.appendItems(documents)
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
func updateDocumentsFromRecentDocs() {
guard searchTextField.text?.isEmpty ?? true else {
return
}
guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String,
!workspaceId.isEmpty
else {
activityIndicator.stopAnimating()
return
}
activityIndicator.startAnimating()
QLService.shared.fetchRecentlyUpdatedDocs(workspaceId: workspaceId, first: 20) { [weak self] docs in
guard let self else { return }
DispatchQueue.main.async {
self.activityIndicator.stopAnimating()
self.updateDocuments(docs.compactMap { DocumentItem(
id: $0.id,
title: $0.title ?? "Unknown Document",
updatedAt: $0.updatedAt?.decoded
) })
}
}
}
func setSelectedDocuments(_ documentAttachments: [DocumentAttachment]) {
selectedDocumentIds = Set(documentAttachments.map(\.documentID))
DispatchQueue.main.async { [weak self] in
self?.tableView.reloadData()
}
}
@@ -142,21 +196,56 @@ class DocumentPickerView: UIView {
@objc private func searchTextChanged() {
guard let text = searchTextField.text else { return }
delegate?.documentPickerView(self, didSearchWithText: text)
}
}
// MARK: - UITableViewDataSource
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
lastSearchKeyword = trimmedText
extension DocumentPickerView: UITableViewDataSource {
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
documents.count
NSObject.cancelPreviousPerformRequests(
withTarget: self,
selector: #selector(performCloudIndexerDocumentSearch(_:)),
object: nil
)
if trimmedText.isEmpty {
DispatchQueue.main.async { [weak self] in
self?.activityIndicator.stopAnimating()
}
updateDocumentsFromRecentDocs()
} else {
DispatchQueue.main.async { [weak self] in
self?.activityIndicator.startAnimating()
}
perform(#selector(performCloudIndexerDocumentSearch(_:)), with: trimmedText, afterDelay: 0.25)
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "DocumentCell", for: indexPath) as! DocumentTableViewCell
cell.configure(with: documents[indexPath.row])
return cell
@objc func performCloudIndexerDocumentSearch(_ keyword: String) {
let trimmedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedKeyword.isEmpty else { return }
guard trimmedKeyword == lastSearchKeyword else { return }
guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String,
!workspaceId.isEmpty
else {
activityIndicator.stopAnimating()
return
}
QLService.shared.searchDocuments(
workspaceId: workspaceId,
keyword: trimmedKeyword,
limit: 20
) { [weak self] searchResults in
guard let self, lastSearchKeyword == trimmedKeyword else {
return
}
DispatchQueue.main.async {
self.activityIndicator.stopAnimating()
self.updateDocuments(searchResults.map {
.init(id: $0.docId, title: $0.title, updatedAt: $0.updatedAt.decoded)
})
}
}
}
}
@@ -169,7 +258,7 @@ extension DocumentPickerView: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let document = documents[indexPath.row]
guard let document = dataSource.itemIdentifier(for: indexPath) else { return }
delegate?.documentPickerView(self, didSelectDocument: document)
}
}
@@ -185,14 +274,14 @@ extension DocumentPickerView: UITableViewDelegate {
let view = DocumentPickerView()
let mockDocuments = [
DocumentItem(title: "Project Proposal.docx", icon: UIImage(systemName: "doc.text")),
DocumentItem(title: "Budget Analysis.xlsx", icon: UIImage(systemName: "tablecells")),
DocumentItem(title: "Meeting Notes.pdf", icon: UIImage(systemName: "doc.richtext")),
DocumentItem(title: "Design Guidelines.sketch", icon: UIImage(systemName: "paintbrush")),
DocumentItem(title: "Code Review.md", icon: UIImage(systemName: "doc.plaintext")),
DocumentItem(title: "User Research.pptx", icon: UIImage(systemName: "doc.on.doc")),
DocumentItem(title: "Technical Specification.docx", icon: UIImage(systemName: "doc.text")),
DocumentItem(title: "Database Schema.sql", icon: UIImage(systemName: "cylinder.split.1x2")),
DocumentItem(id: "1", title: "Project Proposal.docx"),
DocumentItem(id: "2", title: "Budget Analysis.xlsx"),
DocumentItem(id: "3", title: "Meeting Notes.pdf"),
DocumentItem(id: "4", title: "Design Guidelines.sketch"),
DocumentItem(id: "5", title: "Code Review.md"),
DocumentItem(id: "6", title: "User Research.pptx"),
DocumentItem(id: "7", title: "Technical Specification.docx"),
DocumentItem(id: "8", title: "Database Schema.sql"),
]
view.updateDocuments(mockDocuments)

View File

@@ -0,0 +1,12 @@
//
// DocumentPickerViewDelegate.swift
// Intelligents
//
// Created by on 6/25/25.
//
import Foundation
protocol DocumentPickerViewDelegate: AnyObject {
func documentPickerView(_ view: DocumentPickerView, didSelectDocument document: DocumentItem)
}

View File

@@ -25,6 +25,13 @@ class DocumentTableViewCell: UITableViewCell {
$0.textAlignment = .left
}
private lazy var checkmarkImageView = UIImageView().then {
$0.image = UIImage(systemName: "checkmark.circle.fill")
$0.tintColor = .systemBlue
$0.contentMode = .scaleAspectFit
$0.isHidden = true
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
@@ -39,8 +46,11 @@ class DocumentTableViewCell: UITableViewCell {
backgroundColor = .white
selectionStyle = .none
contentView.clipsToBounds = true
contentView.addSubview(iconImageView)
contentView.addSubview(titleLabel)
contentView.addSubview(checkmarkImageView)
iconImageView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(Self.cellInset)
@@ -50,13 +60,20 @@ class DocumentTableViewCell: UITableViewCell {
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(iconImageView.snp.trailing).offset(Self.spacing)
make.trailing.equalTo(checkmarkImageView.snp.leading).offset(-Self.spacing)
make.centerY.equalToSuperview()
}
checkmarkImageView.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-Self.cellInset)
make.centerY.equalToSuperview()
make.width.height.equalTo(Self.iconSize)
}
}
func configure(with document: DocumentItem) {
iconImageView.image = document.icon ?? UIImage(systemName: "doc.text")
func configure(with document: DocumentItem, isSelected: Bool = false) {
iconImageView.image = UIImage(systemName: "doc.text")
titleLabel.text = document.title
checkmarkImageView.isHidden = !isSelected
}
}

View File

@@ -2,11 +2,6 @@ import SnapKit
import Then
import UIKit
protocol FileAttachmentHeaderViewDelegate: AnyObject {
func headerViewDidPickMore(_ headerView: FileAttachmentHeaderView)
func headerViewDidTapManagement(_ headerView: FileAttachmentHeaderView)
}
final class FileAttachmentHeaderView: UIView {
// MARK: - Properties
@@ -131,7 +126,17 @@ final class FileAttachmentHeaderView: UIView {
// MARK: - Public Methods
func updateContent(attachmentCount: Int, docsCount: Int) {
primaryLabel.text = "\(attachmentCount) attachment, \(docsCount) AFFiNE docs"
var components: [String] = []
if attachmentCount > 0 {
components.append("\(attachmentCount) attachment\(attachmentCount > 1 ? "s" : "")")
}
if docsCount > 0 {
components.append("\(docsCount) AFFiNE doc\(docsCount > 1 ? "s" : "")")
}
primaryLabel.text = components.joined(separator: ", ")
}
func setIconImage(_ image: UIImage?) {

View File

@@ -0,0 +1,13 @@
//
// FileAttachmentHeaderViewDelegate.swift
// Intelligents
//
// Created by on 6/25/25.
//
import Foundation
protocol FileAttachmentHeaderViewDelegate: AnyObject {
func headerViewDidPickMore(_ headerView: FileAttachmentHeaderView)
func headerViewDidTapManagement(_ headerView: FileAttachmentHeaderView)
}

View File

@@ -9,10 +9,6 @@ import SnapKit
import Then
import UIKit
protocol ImageAttachmentBarDelegate: AnyObject {
func inputBoxImageBar(_ imageBar: ImageAttachmentBar, didRemoveImageWithId id: UUID)
}
class ImageAttachmentBar: UICollectionView {
weak var imageBarDelegate: ImageAttachmentBarDelegate?

View File

@@ -0,0 +1,12 @@
//
// ImageAttachmentBarDelegate.swift
// Intelligents
//
// Created by on 6/25/25.
//
import Foundation
protocol ImageAttachmentBarDelegate: AnyObject {
func inputBoxImageBar(_ imageBar: ImageAttachmentBar, didRemoveImageWithId id: UUID)
}

View File

@@ -0,0 +1,88 @@
//
// InputBox+Delegates.swift
// Intelligents
//
// Created by on 6/18/25.
//
import SwifterSwift
import UIKit
extension InputBox: ImageAttachmentBarDelegate {
func inputBoxImageBar(_: ImageAttachmentBar, didRemoveImageWithId id: ImageAttachment.ID) {
performWithAnimation { [self] in
viewModel.removeImageAttachment(withId: id)
layoutIfNeeded()
}
}
}
extension InputBox: FileAttachmentHeaderViewDelegate {
func headerViewDidPickMore(_: FileAttachmentHeaderView) {
delegate?.inputBoxDidSelectAttachFiles(self)
}
func headerViewDidTapManagement(_: FileAttachmentHeaderView) {
let controller = AttachmentManagementController(delegate: self)
controller.set(fileAttachments: viewModel.fileAttachments)
controller.set(documentAttachments: viewModel.documentAttachments)
parentViewController?.present(controller, animated: true)
}
}
extension InputBox: AttachmentManagementControllerDelegate {
func deleteFileAttachment(controller: AttachmentManagementController, _ attachment: FileAttachment) {
viewModel.removeFileAttachment(withId: attachment.id)
controller.set(fileAttachments: viewModel.fileAttachments)
layoutIfNeeded()
}
func deleteDocumentAttachment(controller: AttachmentManagementController, _ attachment: DocumentAttachment) {
viewModel.removeDocumentAttachment(withId: attachment.id)
controller.set(documentAttachments: viewModel.documentAttachments)
layoutIfNeeded()
}
}
extension InputBox: InputBoxFunctionBarDelegate {
func functionBarDidTapTakePhoto(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSelectTakePhoto(self)
}
func functionBarDidTapPhotoLibrary(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSelectPhotoLibrary(self)
}
func functionBarDidTapAttachFiles(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSelectAttachFiles(self)
}
func functionBarDidTapEmbedDocs(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSelectEmbedDocs(self)
}
func functionBarDidTapTool(_: InputBoxFunctionBar) {
viewModel.toggleTool()
}
func functionBarDidTapNetwork(_: InputBoxFunctionBar) {
viewModel.toggleNetwork()
}
func functionBarDidTapDeepThinking(_: InputBoxFunctionBar) {
viewModel.toggleDeepThinking()
}
func functionBarDidTapSend(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSend(self)
}
}
extension InputBox: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
viewModel.updateText(textView.text ?? "")
delegate?.inputBoxTextDidChange(textView.text ?? "")
updatePlaceholderVisibility()
updateTextViewHeight()
}
}

View File

@@ -291,6 +291,13 @@ class InputBox: UIView {
}
}
public func addDocumentAttachment(_ documentAttachment: DocumentAttachment) {
performWithAnimation { [self] in
viewModel.addDocumentAttachment(documentAttachment)
layoutIfNeeded()
}
}
public var inputBoxData: InputBoxData {
viewModel.prepareSendData()
}

View File

@@ -1,12 +1,11 @@
//
// InputBoxDelegate.swift
// InputBoxDelegate 2.swift
// Intelligents
//
// Created by on 6/18/25.
// Created by on 6/25/25.
//
import SwifterSwift
import UIKit
import Foundation
protocol InputBoxDelegate: AnyObject {
func inputBoxDidSelectTakePhoto(_ inputBox: InputBox)
@@ -16,82 +15,3 @@ protocol InputBoxDelegate: AnyObject {
func inputBoxDidSend(_ inputBox: InputBox)
func inputBoxTextDidChange(_ text: String)
}
extension InputBox: ImageAttachmentBarDelegate {
func inputBoxImageBar(_: ImageAttachmentBar, didRemoveImageWithId id: ImageAttachment.ID) {
performWithAnimation { [self] in
viewModel.removeImageAttachment(withId: id)
layoutIfNeeded()
}
}
}
extension InputBox: FileAttachmentHeaderViewDelegate {
func headerViewDidPickMore(_: FileAttachmentHeaderView) {
delegate?.inputBoxDidSelectAttachFiles(self)
}
func headerViewDidTapManagement(_: FileAttachmentHeaderView) {
let controller = AttachmentManagementController(delegate: self)
controller.set(fileAttachments: viewModel.fileAttachments)
controller.set(documentAttachments: viewModel.documentAttachments)
parentViewController?.present(controller, animated: true)
}
}
extension InputBox: AttachmentManagementControllerDelegate {
func deleteFileAttachment(controller: AttachmentManagementController, _ attachment: FileAttachment) {
viewModel.removeFileAttachment(withId: attachment.id)
controller.set(fileAttachments: viewModel.fileAttachments)
layoutIfNeeded()
}
func deleteDocumentAttachment(controller: AttachmentManagementController, _ attachment: DocumentAttachment) {
viewModel.removeDocumentAttachment(withId: attachment.id)
controller.set(documentAttachments: viewModel.documentAttachments)
layoutIfNeeded()
}
}
extension InputBox: InputBoxFunctionBarDelegate {
func functionBarDidTapTakePhoto(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSelectTakePhoto(self)
}
func functionBarDidTapPhotoLibrary(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSelectPhotoLibrary(self)
}
func functionBarDidTapAttachFiles(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSelectAttachFiles(self)
}
func functionBarDidTapEmbedDocs(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSelectEmbedDocs(self)
}
func functionBarDidTapTool(_: InputBoxFunctionBar) {
viewModel.toggleTool()
}
func functionBarDidTapNetwork(_: InputBoxFunctionBar) {
viewModel.toggleNetwork()
}
func functionBarDidTapDeepThinking(_: InputBoxFunctionBar) {
viewModel.toggleDeepThinking()
}
func functionBarDidTapSend(_: InputBoxFunctionBar) {
delegate?.inputBoxDidSend(self)
}
}
extension InputBox: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
viewModel.updateText(textView.text ?? "")
delegate?.inputBoxTextDidChange(textView.text ?? "")
updatePlaceholderVisibility()
updateTextViewHeight()
}
}

View File

@@ -2,17 +2,6 @@ import SnapKit
import Then
import UIKit
protocol InputBoxFunctionBarDelegate: AnyObject {
func functionBarDidTapTakePhoto(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapPhotoLibrary(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapAttachFiles(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapEmbedDocs(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapTool(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapNetwork(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapDeepThinking(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapSend(_ functionBar: InputBoxFunctionBar)
}
private let unselectedColor: UIColor = .affineIconPrimary
private let selectedColor: UIColor = .affineIconActivated
@@ -164,7 +153,7 @@ class InputBoxFunctionBar: UIView {
}
let embedDocsAction = UIAction(
title: "Embed AFFINE Docs",
title: "Add AFFiNE Docs",
image: UIImage.affinePage
) { [weak self] _ in
guard let self else { return }

View File

@@ -0,0 +1,19 @@
//
// InputBoxFunctionBarDelegate.swift
// Intelligents
//
// Created by on 6/25/25.
//
import Foundation
protocol InputBoxFunctionBarDelegate: AnyObject {
func functionBarDidTapTakePhoto(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapPhotoLibrary(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapAttachFiles(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapEmbedDocs(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapTool(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapNetwork(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapDeepThinking(_ functionBar: InputBoxFunctionBar)
func functionBarDidTapSend(_ functionBar: InputBoxFunctionBar)
}

View File

@@ -12,4 +12,13 @@ public struct DocumentAttachment: Identifiable, Equatable, Hashable, Codable {
public var title: String = ""
public var workspaceID: String = ""
public var documentID: String = ""
public var updatedAt: Date?
public init(id: UUID = .init(), title: String = "", workspaceID: String = "", documentID: String = "", updatedAt: Date? = nil) {
self.id = id
self.title = title
self.workspaceID = workspaceID
self.documentID = documentID
self.updatedAt = updatedAt
}
}

View File

@@ -2,12 +2,6 @@ import SnapKit
import Then
import UIKit
protocol MainHeaderViewDelegate: AnyObject {
func mainHeaderViewDidTapClose()
func mainHeaderViewDidTapDropdown()
func mainHeaderViewDidTapMenu()
}
class MainHeaderView: UIView {
weak var delegate: MainHeaderViewDelegate?

View File

@@ -0,0 +1,14 @@
//
// MainHeaderViewDelegate.swift
// Intelligents
//
// Created by on 6/25/25.
//
import Foundation
protocol MainHeaderViewDelegate: AnyObject {
func mainHeaderViewDidTapClose()
func mainHeaderViewDidTapDropdown()
func mainHeaderViewDidTapMenu()
}