mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
After Start sharing succeeds, jump straight to the new transfer's share panel (QR code + delivery actions) instead of returning to the list and making the user drill in via the row and the share row. Refresh first so the transfer exists in state before selecting it; the share panel already handles the brief window before the ticket is ready.
362 lines
12 KiB
Swift
362 lines
12 KiB
Swift
import Foundation
|
|
import Combine
|
|
|
|
enum TransferDetailPanel: Equatable {
|
|
case activity
|
|
case receivers
|
|
case share
|
|
}
|
|
|
|
/// Which invitation delivery action produced a result (for success copy).
|
|
enum InvitationAction {
|
|
case export
|
|
case share
|
|
case nfc
|
|
}
|
|
|
|
/// Send feature state, ported from `feature/send/SendViewModel.kt` (`SendState`).
|
|
struct SendState: Equatable {
|
|
var isComposerOpen = false
|
|
var selectedFiles: [PickedShareFile] = []
|
|
var transferName = ""
|
|
var senderName = ""
|
|
var accessPolicy: ShareAccessPolicy = .requireApproval
|
|
var isSharing = false
|
|
var selectedTransferId: UInt64?
|
|
var transferThumbnails: [UInt64: Data] = [:]
|
|
var detailPanel: TransferDetailPanel?
|
|
var receiverHistory: [ReceiverRequestModel] = []
|
|
var isLoadingReceivers = false
|
|
var isDeleteConfirmationOpen = false
|
|
var isDeleting = false
|
|
|
|
func canCreateShare(coreInitialized: Bool) -> Bool {
|
|
coreInitialized && !selectedFiles.isEmpty && !transferName.isEmpty && !isSharing
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class SendModel: ObservableObject {
|
|
@Published private(set) var state = SendState()
|
|
@Published private(set) var coreState = CoreState()
|
|
|
|
/// Requests a file/folder pick or clipboard copy, consumed by the view layer.
|
|
@Published var pendingFilePick = false
|
|
@Published var pendingFolderPick = false
|
|
|
|
/// Receiver delivery records per active (sharing) transfer, used to decide
|
|
/// which transfers still have an in-flight receiver. Delivery status is the
|
|
/// authoritative signal; byte-transfer events alone don't reliably mark a
|
|
/// small transfer complete.
|
|
@Published private(set) var receiversByTransfer: [UInt64: [ReceiverRequestModel]] = [:]
|
|
|
|
private let repository: CoreGateway
|
|
private let fileSystemService: FileSystemService
|
|
private let filePreviewRepository: FilePreviewRepository
|
|
private let messages: UiMessageController
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
init(
|
|
repository: CoreGateway,
|
|
fileSystemService: FileSystemService,
|
|
preferences: AppPreferencesRepository,
|
|
filePreviewRepository: FilePreviewRepository,
|
|
messages: UiMessageController
|
|
) {
|
|
self.repository = repository
|
|
self.fileSystemService = fileSystemService
|
|
self.filePreviewRepository = filePreviewRepository
|
|
self.messages = messages
|
|
|
|
repository.statePublisher.sink { [weak self] in self?.coreState = $0 }.store(in: &cancellables)
|
|
|
|
repository.signals
|
|
.sink { [weak self] signal in
|
|
guard let self else { return }
|
|
switch signal {
|
|
case .transfersChanged:
|
|
Task { _ = await self.repository.refresh() }
|
|
case .receiverHistoryChanged(let id), .approvalChanged(let id):
|
|
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
|
|
self.refreshReceiverStatuses(for: id)
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
// Keep receiver delivery records current for every sharing/importing
|
|
// outgoing transfer (new shares appear here; status transitions arrive via
|
|
// the receiverHistoryChanged signal above).
|
|
repository.statePublisher
|
|
.map { core -> Set<UInt64> in
|
|
Set(core.transfers
|
|
.filter { $0.direction == .send && ($0.status == .sharing || $0.status == .importing) }
|
|
.map(\.transferId))
|
|
}
|
|
.removeDuplicates()
|
|
.sink { [weak self] ids in self?.syncSharingReceivers(ids) }
|
|
.store(in: &cancellables)
|
|
|
|
filePreviewRepository.$previews
|
|
.sink { [weak self] previews in self?.state.transferThumbnails = previews }
|
|
.store(in: &cancellables)
|
|
|
|
repository.statePublisher
|
|
.map { core -> Set<UInt64>? in
|
|
core.isInitialized ? Set(core.transfers.map(\.transferId)) : nil
|
|
}
|
|
.removeDuplicates()
|
|
.sink { [weak self] ids in
|
|
if let ids { self?.filePreviewRepository.restore(activeTransferIds: ids) }
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
preferences.$preferences
|
|
.sink { [weak self] prefs in
|
|
guard let self else { return }
|
|
if self.state.senderName.isEmpty { self.state.senderName = prefs.username }
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
// MARK: - Composer
|
|
|
|
func openComposer() {
|
|
if state.isSharing { return }
|
|
let discarded = state.selectedFiles
|
|
state.isComposerOpen = true
|
|
state.selectedFiles = []
|
|
state.transferName = ""
|
|
state.accessPolicy = .requireApproval
|
|
discardPickedFiles(discarded)
|
|
}
|
|
|
|
func dismissComposer() {
|
|
if state.isSharing { return }
|
|
let discarded = state.selectedFiles
|
|
state.isComposerOpen = false
|
|
state.selectedFiles = []
|
|
state.transferName = ""
|
|
state.accessPolicy = .requireApproval
|
|
discardPickedFiles(discarded)
|
|
}
|
|
|
|
func selectFile() { pendingFilePick = true }
|
|
func selectFolder() { pendingFolderPick = true }
|
|
|
|
func onFilesPicked(_ files: [PickedShareFile]) {
|
|
if files.isEmpty { return }
|
|
let selectedValues = Set(files.map(\.value))
|
|
let discarded = state.selectedFiles.filter { !selectedValues.contains($0.value) }
|
|
state.isComposerOpen = true
|
|
state.selectedFiles = files
|
|
state.transferName = defaultTransferName(files)
|
|
discardPickedFiles(discarded)
|
|
}
|
|
|
|
func onFilePickFailed(_ reason: String) {
|
|
messages.error(InvitationError.message(reason.isEmpty ? "selection failed" : reason))
|
|
}
|
|
|
|
func clearSelectedSource() {
|
|
let discarded = state.selectedFiles
|
|
state.selectedFiles = []
|
|
state.transferName = ""
|
|
discardPickedFiles(discarded)
|
|
}
|
|
|
|
func removeSelectedFile(_ value: String) {
|
|
let discarded = state.selectedFiles.filter { $0.value == value }
|
|
let remaining = state.selectedFiles.filter { $0.value != value }
|
|
let wasDefault = state.transferName == defaultTransferName(state.selectedFiles)
|
|
state.selectedFiles = remaining
|
|
state.transferName = remaining.isEmpty ? "" : (wasDefault ? defaultTransferName(remaining) : state.transferName)
|
|
discardPickedFiles(discarded)
|
|
}
|
|
|
|
func setTransferName(_ value: String) { state.transferName = value }
|
|
func setSenderName(_ value: String) { state.senderName = value }
|
|
func setAccessPolicy(_ value: ShareAccessPolicy) { state.accessPolicy = value }
|
|
|
|
// MARK: - Transfer detail
|
|
|
|
func openTransfer(_ transferId: UInt64) {
|
|
state.selectedTransferId = transferId
|
|
state.detailPanel = nil
|
|
refreshReceivers(transferId)
|
|
}
|
|
|
|
func closeTransferDetails() {
|
|
state.selectedTransferId = nil
|
|
state.detailPanel = nil
|
|
state.receiverHistory = []
|
|
state.isDeleteConfirmationOpen = false
|
|
}
|
|
|
|
func openActivity() { state.detailPanel = .activity }
|
|
func openShare() { state.detailPanel = .share }
|
|
func openReceivers() {
|
|
guard let id = state.selectedTransferId else { return }
|
|
state.detailPanel = .receivers
|
|
refreshReceivers(id)
|
|
}
|
|
func closeDetailPanel() { state.detailPanel = nil }
|
|
|
|
func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
|
|
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
|
|
|
|
func confirmDeleteTransfer() {
|
|
guard let transferId = state.selectedTransferId, !state.isDeleting else { return }
|
|
state.isDeleting = true
|
|
// Close synchronously: the alert's dismiss binding runs async and no-ops while
|
|
// `isDeleting`, which would otherwise leave the flag true and macOS re-present it.
|
|
state.isDeleteConfirmationOpen = false
|
|
Task {
|
|
let result = await repository.delete(transferId: transferId)
|
|
switch result {
|
|
case .success:
|
|
filePreviewRepository.remove(transferId: transferId)
|
|
state.selectedTransferId = nil
|
|
state.detailPanel = nil
|
|
state.receiverHistory = []
|
|
state.isDeleteConfirmationOpen = false
|
|
state.isDeleting = false
|
|
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.deleted), tone: .success))
|
|
case .failure(let error):
|
|
state.isDeleting = false
|
|
messages.error(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cancels/refuses a single receiver by responding to its request negatively.
|
|
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
|
/// receivers that are still pending or accepted.
|
|
func cancelReceiver(requestId: String) {
|
|
Task {
|
|
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil)
|
|
switch result {
|
|
case .success:
|
|
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }
|
|
_ = await repository.refresh()
|
|
case .failure(let error):
|
|
messages.error(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stops an active outgoing share (interrupts any in-flight receivers). The
|
|
/// transfer stays in history as "Stopped". Uses the core's `cancelTransfer`.
|
|
func stopSharing(transferId: UInt64) {
|
|
Task {
|
|
let result = await repository.cancel(transferId: transferId)
|
|
switch result {
|
|
case .success:
|
|
_ = await repository.refresh()
|
|
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.eventStopped), tone: .info))
|
|
case .failure(let error):
|
|
messages.error(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Invitation results / share
|
|
|
|
func onInvitationResult(_ action: InvitationAction, _ result: Result<Void, Error>) {
|
|
switch result {
|
|
case .success:
|
|
let key: String.LocalizationValue?
|
|
switch action {
|
|
case .export: key = L10n.Transfer.invitationSaved
|
|
case .nfc: key = L10n.Transfer.nfcWritten
|
|
case .share: key = nil // system share sheet already confirms
|
|
}
|
|
if let key { messages.tryShow(UiMessage(text: .resource(key), tone: .success)) }
|
|
case .failure(let error):
|
|
messages.error(error)
|
|
}
|
|
}
|
|
|
|
func createShare() {
|
|
let current = state
|
|
if current.selectedFiles.isEmpty { return }
|
|
if !current.canCreateShare(coreInitialized: coreState.isInitialized) { return }
|
|
state.isSharing = true
|
|
Task {
|
|
let result = await fileSystemService.sharePickedFiles(
|
|
repository: repository,
|
|
files: current.selectedFiles,
|
|
transferName: current.transferName.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
accessPolicy: current.accessPolicy
|
|
)
|
|
switch result {
|
|
case .success(let share):
|
|
await fileSystemService.discardPickedFiles(current.selectedFiles)
|
|
if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first {
|
|
filePreviewRepository.save(transferId: share.transferId, bytes: thumb)
|
|
}
|
|
state.isComposerOpen = false
|
|
state.selectedFiles = []
|
|
state.transferName = ""
|
|
state.accessPolicy = .requireApproval
|
|
state.isSharing = false
|
|
// Jump straight to the new transfer's share panel (QR + delivery) rather
|
|
// than dropping the user on the list to drill in manually. Refresh first
|
|
// so the transfer exists in state before it's selected.
|
|
_ = await repository.refresh()
|
|
state.selectedTransferId = share.transferId
|
|
state.detailPanel = .share
|
|
refreshReceivers(share.transferId)
|
|
messages.show(UiMessage(text: .resource(L10n.Send.transferCreated), tone: .success))
|
|
case .failure(let error):
|
|
state.isSharing = false
|
|
messages.error(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Internals
|
|
|
|
private func defaultTransferName(_ files: [PickedShareFile]) -> String {
|
|
if files.isEmpty { return "" }
|
|
if files.count == 1 { return files[0].displayName }
|
|
if files.allSatisfy(\.isDirectory) { return "\(files.count) folders" }
|
|
return "\(files.count) files"
|
|
}
|
|
|
|
private func discardPickedFiles(_ files: [PickedShareFile]) {
|
|
if files.isEmpty { return }
|
|
Task { await fileSystemService.discardPickedFiles(files) }
|
|
}
|
|
|
|
/// Refresh the receiver records for the sharing set, pruning transfers that are
|
|
/// no longer active.
|
|
private func syncSharingReceivers(_ ids: Set<UInt64>) {
|
|
receiversByTransfer = receiversByTransfer.filter { ids.contains($0.key) }
|
|
for id in ids { refreshReceiverStatuses(for: id) }
|
|
}
|
|
|
|
private func refreshReceiverStatuses(for transferId: UInt64) {
|
|
Task {
|
|
if case .success(let requests) = await repository.receiverRequests(transferId: transferId) {
|
|
receiversByTransfer[transferId] = requests
|
|
}
|
|
}
|
|
}
|
|
|
|
private func refreshReceivers(_ transferId: UInt64) {
|
|
state.isLoadingReceivers = true
|
|
Task {
|
|
let result = await repository.receiverRequests(transferId: transferId)
|
|
switch result {
|
|
case .success(let requests):
|
|
state.receiverHistory = requests
|
|
state.isLoadingReceivers = false
|
|
case .failure(let error):
|
|
state.isLoadingReceivers = false
|
|
messages.error(error)
|
|
}
|
|
}
|
|
}
|
|
}
|