1234 lines
43 KiB
Swift
1234 lines
43 KiB
Swift
import ImageIO
|
|
import Observation
|
|
import PhotosUI
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
import UIKit
|
|
|
|
struct SybilWorkspaceView: View {
|
|
@Bindable var viewModel: SybilViewModel
|
|
var composerFocusRequest: Int = 0
|
|
var usesCustomChatNavigation: Bool = false
|
|
var onRequestNewChat: (() -> Void)? = nil
|
|
@FocusState private var composerFocused: Bool
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var isShowingAttachmentOptions = false
|
|
@State private var isShowingFileImporter = false
|
|
@State private var isShowingPhotoPicker = false
|
|
@State private var photoPickerItems: [PhotosPickerItem] = []
|
|
@State private var isComposerDropTargeted = false
|
|
@State private var newChatSwipeOffset: CGFloat = 0
|
|
@State private var newChatSwipeCompletionOffset: CGFloat = 0
|
|
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
|
|
@State private var newChatSwipeIsActive = false
|
|
@State private var newChatSwipeIsCompleting = false
|
|
@State private var newChatSwipeHasLatched = false
|
|
@State private var newChatSwipeDidTriggerHaptic = false
|
|
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
|
|
|
|
private let customChatNavigationContentInset: CGFloat = 96
|
|
|
|
private var isSettingsSelected: Bool {
|
|
if case .settings = viewModel.selectedItem {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private var showsHeader: Bool {
|
|
viewModel.errorMessage != nil
|
|
}
|
|
|
|
private var showsCustomChatNavigation: Bool {
|
|
usesCustomChatNavigation && !isSettingsSelected && !viewModel.isSearchMode
|
|
}
|
|
|
|
private var transcriptScrollContextID: String {
|
|
if viewModel.draftKind == .chat {
|
|
return "draft-chat"
|
|
}
|
|
if case let .chat(chatID) = viewModel.selectedItem {
|
|
return "chat:\(chatID)"
|
|
}
|
|
return "chat:none"
|
|
}
|
|
|
|
private var canSwipeToCreateChat: Bool {
|
|
guard onRequestNewChat != nil else {
|
|
return false
|
|
}
|
|
guard !viewModel.isSending, viewModel.draftKind == nil else {
|
|
return false
|
|
}
|
|
guard case .chat = viewModel.selectedItem else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private var canRecognizeNewChatSwipe: Bool {
|
|
canSwipeToCreateChat && !newChatSwipeIsCompleting
|
|
}
|
|
|
|
private var showsNewChatSwipeBackdrop: Bool {
|
|
canSwipeToCreateChat || newChatSwipeIsCompleting
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .trailing) {
|
|
if showsNewChatSwipeBackdrop {
|
|
NewChatSwipeBackdrop(
|
|
progress: NewChatSwipeMetrics.progress(for: newChatSwipeOffset, width: newChatSwipeContainerWidth),
|
|
hasLatched: newChatSwipeHasLatched
|
|
)
|
|
.padding(.trailing, 18)
|
|
.padding(.vertical, 20)
|
|
.allowsHitTesting(false)
|
|
}
|
|
|
|
workspaceContent
|
|
.compositingGroup()
|
|
.offset(x: newChatSwipeOffset)
|
|
.blur(radius: NewChatSwipeMetrics.blurRadius(for: newChatSwipeOffset, width: newChatSwipeContainerWidth))
|
|
}
|
|
.offset(x: newChatSwipeCompletionOffset)
|
|
.background(SybilTheme.background)
|
|
.navigationTitle(showsCustomChatNavigation ? "" : viewModel.selectedTitle)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbarRole(.editor)
|
|
.toolbar(showsCustomChatNavigation ? .hidden : .visible, for: .navigationBar)
|
|
.toolbar {
|
|
if !isSettingsSelected && !showsCustomChatNavigation {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
if viewModel.isSearchMode {
|
|
searchModeChip
|
|
} else {
|
|
providerModelToolbarMenu
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.onChange(of: canSwipeToCreateChat) { _, isEnabled in
|
|
guard !isEnabled else {
|
|
return
|
|
}
|
|
resetNewChatSwipe(animated: false)
|
|
}
|
|
.task(id: composerFocusRequest) {
|
|
await focusComposerIfRequested()
|
|
}
|
|
}
|
|
|
|
private var workspaceContent: some View {
|
|
ZStack(alignment: .top) {
|
|
workspaceContentStack
|
|
|
|
if showsCustomChatNavigation {
|
|
SybilChatCharacterBackdrop(isBusy: viewModel.isSending)
|
|
.allowsHitTesting(false)
|
|
customChatNavigationBar
|
|
}
|
|
}
|
|
}
|
|
|
|
private var workspaceContentStack: some View {
|
|
VStack(spacing: 0) {
|
|
if showsHeader {
|
|
header
|
|
|
|
Divider()
|
|
.overlay(SybilTheme.border)
|
|
}
|
|
|
|
Group {
|
|
if isSettingsSelected {
|
|
SybilSettingsView(viewModel: viewModel)
|
|
} else if viewModel.isSearchMode {
|
|
SybilSearchResultsView(
|
|
search: viewModel.selectedSearch,
|
|
isLoading: viewModel.isLoadingSelection,
|
|
isRunning: viewModel.isSending,
|
|
isStartingChat: viewModel.isCreatingSearchChat
|
|
) {
|
|
Task {
|
|
await viewModel.startChatFromSelectedSearch()
|
|
}
|
|
}
|
|
} else {
|
|
SybilChatTranscriptView(
|
|
messages: viewModel.displayedMessages,
|
|
isLoading: viewModel.isLoadingSelection,
|
|
isSending: viewModel.isSending,
|
|
topContentInset: showsCustomChatNavigation ? customChatNavigationContentInset : 0
|
|
)
|
|
.id(transcriptScrollContextID)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background {
|
|
NewChatSwipePanInstaller(
|
|
isEnabled: canRecognizeNewChatSwipe,
|
|
onBegan: { width in
|
|
beginNewChatSwipe(containerWidth: width)
|
|
},
|
|
onChanged: { translationX, width in
|
|
updateNewChatSwipe(with: translationX, containerWidth: width)
|
|
},
|
|
onEnded: { translationX, width, didFinish in
|
|
finishNewChatSwipe(
|
|
translationX: translationX,
|
|
containerWidth: width,
|
|
didFinish: didFinish
|
|
)
|
|
}
|
|
)
|
|
}
|
|
|
|
if viewModel.showsComposer {
|
|
Divider()
|
|
.overlay(SybilTheme.border)
|
|
composerBar
|
|
}
|
|
}
|
|
}
|
|
|
|
private var customChatNavigationBar: some View {
|
|
HStack(spacing: 14) {
|
|
Button {
|
|
dismiss()
|
|
} label: {
|
|
SybilNavigationIcon(systemImage: "chevron.left")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Back")
|
|
|
|
Text(viewModel.selectedTitle)
|
|
.font(.sybil(size: 16, weight: .semibold))
|
|
.foregroundStyle(SybilTheme.text)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.78)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
providerModelNavigationMenu
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 10)
|
|
.padding(.bottom, 34)
|
|
.background(alignment: .top) {
|
|
SybilNavigationFadeBackground()
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
|
|
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
|
let update = {
|
|
newChatSwipeContainerWidth = max(containerWidth, 1)
|
|
newChatSwipeIsActive = true
|
|
newChatSwipeHasLatched = false
|
|
newChatSwipeDidTriggerHaptic = false
|
|
}
|
|
|
|
var transaction = Transaction()
|
|
transaction.disablesAnimations = true
|
|
withTransaction(transaction, update)
|
|
|
|
if newChatSwipeFeedbackGenerator == nil {
|
|
newChatSwipeFeedbackGenerator = UIImpactFeedbackGenerator(style: .rigid)
|
|
}
|
|
newChatSwipeFeedbackGenerator?.prepare()
|
|
}
|
|
|
|
private func updateNewChatSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
|
|
let nextOffset = NewChatSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
|
|
let wasLatched = newChatSwipeHasLatched
|
|
let nextLatched = NewChatSwipeMetrics.isLatched(
|
|
offset: nextOffset,
|
|
width: containerWidth,
|
|
isCurrentlyLatched: newChatSwipeHasLatched
|
|
)
|
|
|
|
var transaction = Transaction()
|
|
transaction.disablesAnimations = true
|
|
withTransaction(transaction) {
|
|
newChatSwipeContainerWidth = max(containerWidth, 1)
|
|
newChatSwipeOffset = nextOffset
|
|
newChatSwipeHasLatched = nextLatched
|
|
}
|
|
|
|
if nextLatched && !wasLatched && !newChatSwipeDidTriggerHaptic {
|
|
newChatSwipeFeedbackGenerator?.impactOccurred(intensity: 0.95)
|
|
newChatSwipeDidTriggerHaptic = true
|
|
}
|
|
}
|
|
|
|
private func finishNewChatSwipe(translationX: CGFloat, containerWidth: CGFloat, didFinish: Bool) {
|
|
guard newChatSwipeIsActive else {
|
|
resetNewChatSwipe(animated: false)
|
|
return
|
|
}
|
|
|
|
updateNewChatSwipe(with: translationX, containerWidth: containerWidth)
|
|
|
|
if didFinish && newChatSwipeHasLatched {
|
|
Task {
|
|
await completeNewChatSwipe(containerWidth: containerWidth)
|
|
}
|
|
return
|
|
}
|
|
|
|
resetNewChatSwipe(animated: true)
|
|
}
|
|
|
|
@MainActor
|
|
private func completeNewChatSwipe(containerWidth: CGFloat) async {
|
|
newChatSwipeIsCompleting = true
|
|
|
|
withAnimation(.easeIn(duration: NewChatSwipeMetrics.completionAnimationDuration)) {
|
|
newChatSwipeCompletionOffset = -(containerWidth + NewChatSwipeMetrics.completionOvershoot)
|
|
}
|
|
|
|
try? await Task.sleep(for: .milliseconds(NewChatSwipeMetrics.completionAnimationDelayMs))
|
|
onRequestNewChat?()
|
|
resetNewChatSwipe(animated: false)
|
|
}
|
|
|
|
private func resetNewChatSwipe(animated: Bool) {
|
|
let reset = {
|
|
newChatSwipeOffset = 0
|
|
newChatSwipeCompletionOffset = 0
|
|
newChatSwipeIsActive = false
|
|
newChatSwipeIsCompleting = false
|
|
newChatSwipeHasLatched = false
|
|
newChatSwipeDidTriggerHaptic = false
|
|
}
|
|
|
|
if animated {
|
|
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
|
|
reset()
|
|
}
|
|
} else {
|
|
reset()
|
|
}
|
|
|
|
newChatSwipeFeedbackGenerator = nil
|
|
}
|
|
|
|
@MainActor
|
|
private func focusComposerIfRequested() async {
|
|
guard composerFocusRequest > 0 else {
|
|
return
|
|
}
|
|
|
|
await Task.yield()
|
|
try? await Task.sleep(for: .milliseconds(80))
|
|
|
|
guard viewModel.showsComposer, !viewModel.isSearchMode else {
|
|
return
|
|
}
|
|
composerFocused = true
|
|
}
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
if let error = viewModel.errorMessage {
|
|
Text(error)
|
|
.font(.sybil(.footnote))
|
|
.foregroundStyle(SybilTheme.danger)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.background(SybilTheme.panelGradient.opacity(0.58))
|
|
}
|
|
|
|
private var providerModelToolbarMenu: some View {
|
|
providerModelMenu {
|
|
Image(systemName: "ellipsis")
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundStyle(SybilTheme.text)
|
|
.frame(width: 34, height: 34)
|
|
.background(
|
|
Circle()
|
|
.fill(SybilTheme.surface.opacity(0.78))
|
|
)
|
|
.overlay(
|
|
Circle()
|
|
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
private var providerModelNavigationMenu: some View {
|
|
providerModelMenu {
|
|
SybilNavigationIcon(systemImage: "ellipsis")
|
|
}
|
|
}
|
|
|
|
private func providerModelMenu<Label: View>(@ViewBuilder label: @escaping () -> Label) -> some View {
|
|
Menu {
|
|
providerModelMenuItems
|
|
} label: {
|
|
label()
|
|
}
|
|
.accessibilityLabel("Provider and model")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var providerModelMenuItems: some View {
|
|
Text("\(viewModel.provider.displayName) • \(viewModel.model)")
|
|
.font(.sybil(.caption))
|
|
|
|
Divider()
|
|
|
|
ForEach(Provider.allCases, id: \.self) { candidate in
|
|
Menu(candidate.displayName) {
|
|
let models = viewModel.modelOptions(for: candidate)
|
|
if models.isEmpty {
|
|
Text("No models")
|
|
} else {
|
|
ForEach(models, id: \.self) { candidateModel in
|
|
Button {
|
|
viewModel.setProvider(candidate, model: candidateModel)
|
|
} label: {
|
|
if viewModel.provider == candidate && viewModel.model == candidateModel {
|
|
Label(candidateModel, systemImage: "checkmark")
|
|
} else {
|
|
Text(candidateModel)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var searchModeChip: some View {
|
|
Label("Search", systemImage: "globe")
|
|
.font(.sybil(.caption, weight: .medium))
|
|
.foregroundStyle(SybilTheme.accent)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 7)
|
|
.background(
|
|
Capsule()
|
|
.fill(SybilTheme.accent.opacity(0.10))
|
|
.overlay(
|
|
Capsule()
|
|
.stroke(SybilTheme.accent.opacity(0.24), lineWidth: 1)
|
|
)
|
|
)
|
|
}
|
|
|
|
private var composerBar: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
if !viewModel.isSearchMode && !viewModel.composerAttachments.isEmpty {
|
|
SybilAttachmentListView(
|
|
attachments: viewModel.composerAttachments,
|
|
tone: .composer
|
|
) { attachmentID in
|
|
viewModel.removeComposerAttachment(id: attachmentID)
|
|
}
|
|
}
|
|
|
|
HStack(alignment: .bottom, spacing: 10) {
|
|
if !viewModel.isSearchMode {
|
|
Button {
|
|
isShowingAttachmentOptions = true
|
|
} label: {
|
|
Image(systemName: "paperclip")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.frame(width: 40, height: 40)
|
|
.background(
|
|
Circle()
|
|
.fill(SybilTheme.surface)
|
|
)
|
|
.overlay(
|
|
Circle()
|
|
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
|
)
|
|
.foregroundStyle(viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(viewModel.isSending)
|
|
.accessibilityLabel("Attach file")
|
|
}
|
|
|
|
TextField(
|
|
viewModel.isSearchMode ? "Search the web" : "Message Sybil",
|
|
text: $viewModel.composer,
|
|
axis: .vertical
|
|
)
|
|
.focused($composerFocused)
|
|
.textInputAutocapitalization(.sentences)
|
|
.autocorrectionDisabled(false)
|
|
.lineLimit(1 ... 6)
|
|
.submitLabel(.send)
|
|
.onSubmit {
|
|
submitComposer()
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(SybilTheme.composerGradient)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(SybilTheme.primary.opacity(0.34), lineWidth: 1)
|
|
)
|
|
)
|
|
.foregroundStyle(SybilTheme.text)
|
|
|
|
Button {
|
|
submitComposer()
|
|
} label: {
|
|
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.frame(width: 40, height: 40)
|
|
.background(
|
|
Circle()
|
|
.fill(
|
|
viewModel.canSendComposer
|
|
? AnyShapeStyle(SybilTheme.primaryGradient)
|
|
: AnyShapeStyle(SybilTheme.surface)
|
|
)
|
|
)
|
|
.foregroundStyle(viewModel.canSendComposer ? SybilTheme.text : SybilTheme.textMuted)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!viewModel.canSendComposer)
|
|
}
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 12)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [
|
|
SybilTheme.background.opacity(0.18),
|
|
SybilTheme.background.opacity(0.96)
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.overlay {
|
|
if isComposerDropTargeted && !viewModel.isSearchMode {
|
|
RoundedRectangle(cornerRadius: 18)
|
|
.stroke(SybilTheme.accent.opacity(0.78), style: StrokeStyle(lineWidth: 1.5, dash: [7, 5]))
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
}
|
|
}
|
|
.onDrop(of: [UTType.fileURL.identifier, UTType.image.identifier], isTargeted: $isComposerDropTargeted) { providers in
|
|
if viewModel.isSearchMode || viewModel.isSending {
|
|
return false
|
|
}
|
|
|
|
Task {
|
|
await importAttachmentsFromItemProviders(providers)
|
|
}
|
|
return true
|
|
}
|
|
.confirmationDialog("Add attachment", isPresented: $isShowingAttachmentOptions, titleVisibility: .visible) {
|
|
Button("Photo Library") {
|
|
isShowingPhotoPicker = true
|
|
}
|
|
Button("Files") {
|
|
isShowingFileImporter = true
|
|
}
|
|
Button("Paste from Clipboard") {
|
|
Task {
|
|
await pasteAttachmentsFromClipboard()
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
}
|
|
.photosPicker(
|
|
isPresented: $isShowingPhotoPicker,
|
|
selection: $photoPickerItems,
|
|
maxSelectionCount: max(1, SybilChatAttachmentSupport.maxAttachmentsPerMessage - viewModel.composerAttachments.count),
|
|
matching: .images
|
|
)
|
|
.fileImporter(
|
|
isPresented: $isShowingFileImporter,
|
|
allowedContentTypes: [.item],
|
|
allowsMultipleSelection: true
|
|
) { result in
|
|
Task {
|
|
do {
|
|
let urls = try result.get()
|
|
let attachments = try SybilChatAttachmentSupport.buildAttachments(from: urls)
|
|
try await MainActor.run {
|
|
try viewModel.appendComposerAttachments(attachments)
|
|
}
|
|
composerFocused = true
|
|
} catch {
|
|
await MainActor.run {
|
|
viewModel.errorMessage = error.localizedDescription
|
|
}
|
|
SybilLog.error(SybilLog.ui, "File import failed", error: error)
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: photoPickerItems) { _, items in
|
|
guard !items.isEmpty else { return }
|
|
Task {
|
|
do {
|
|
let attachments = try await loadAttachmentsFromPhotoPickerItems(items)
|
|
try await MainActor.run {
|
|
try viewModel.appendComposerAttachments(attachments)
|
|
photoPickerItems = []
|
|
}
|
|
composerFocused = true
|
|
} catch {
|
|
await MainActor.run {
|
|
viewModel.errorMessage = error.localizedDescription
|
|
photoPickerItems = []
|
|
}
|
|
SybilLog.error(SybilLog.ui, "Photo import failed", error: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func submitComposer() {
|
|
guard viewModel.canSendComposer else {
|
|
return
|
|
}
|
|
|
|
#if !targetEnvironment(macCatalyst)
|
|
if !viewModel.isSearchMode {
|
|
composerFocused = false
|
|
}
|
|
#endif
|
|
|
|
Task {
|
|
await viewModel.sendComposer()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async {
|
|
do {
|
|
let attachments = try await SybilChatAttachmentSupport.buildAttachments(from: providers)
|
|
try viewModel.appendComposerAttachments(attachments)
|
|
composerFocused = true
|
|
} catch {
|
|
viewModel.errorMessage = error.localizedDescription
|
|
SybilLog.error(SybilLog.ui, "Clipboard/drop attachment import failed", error: error)
|
|
}
|
|
}
|
|
|
|
private func loadAttachmentsFromPhotoPickerItems(_ items: [PhotosPickerItem]) async throws -> [ChatAttachment] {
|
|
var attachments: [ChatAttachment] = []
|
|
|
|
for item in items {
|
|
guard let data = try await item.loadTransferable(type: Data.self) else {
|
|
continue
|
|
}
|
|
|
|
let contentType = item.supportedContentTypes.first(where: { $0.conforms(to: .image) })
|
|
let filename = contentType?.preferredFilenameExtension.map { "photo.\($0)" } ?? "photo.jpg"
|
|
attachments.append(try SybilChatAttachmentSupport.buildImageAttachment(data: data, filename: filename, contentType: contentType))
|
|
}
|
|
|
|
return attachments
|
|
}
|
|
|
|
@MainActor
|
|
private func pasteAttachmentsFromClipboard() async {
|
|
do {
|
|
let pasteboard = UIPasteboard.general
|
|
var attachments: [ChatAttachment] = []
|
|
|
|
if let image = pasteboard.image {
|
|
attachments.append(try SybilChatAttachmentSupport.buildImageAttachment(image: image))
|
|
}
|
|
|
|
if let url = pasteboard.url, url.isFileURL {
|
|
attachments.append(contentsOf: try SybilChatAttachmentSupport.buildAttachments(from: [url]))
|
|
} else if let text = pasteboard.string?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
|
attachments.append(try SybilChatAttachmentSupport.buildTextAttachment(text: text))
|
|
}
|
|
|
|
guard !attachments.isEmpty else {
|
|
viewModel.errorMessage = "Clipboard does not contain a supported attachment."
|
|
return
|
|
}
|
|
|
|
try viewModel.appendComposerAttachments(attachments)
|
|
composerFocused = true
|
|
} catch {
|
|
viewModel.errorMessage = error.localizedDescription
|
|
SybilLog.error(SybilLog.ui, "Clipboard attachment import failed", error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum NewChatSwipeMetrics {
|
|
static let referenceWidth: CGFloat = 390
|
|
static let horizontalActivationDistance: CGFloat = 18
|
|
static let directionDominanceRatio: CGFloat = 1.22
|
|
static let minimumLeftwardVelocity: CGFloat = 55
|
|
static let latchHysteresis: CGFloat = 32
|
|
static let completionOvershoot: CGFloat = 180
|
|
static let completionAnimationDuration = 0.24
|
|
static let completionAnimationDelayMs: UInt64 = 240
|
|
|
|
static func maxTravel(for width: CGFloat) -> CGFloat {
|
|
min(max(width * 0.46, 156), 240)
|
|
}
|
|
|
|
static func latchDistance(for width: CGFloat) -> CGFloat {
|
|
min(max(width * 0.28, 112), 152)
|
|
}
|
|
|
|
static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
|
|
max(min(rawTranslation, 0), -maxTravel(for: width))
|
|
}
|
|
|
|
static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
|
let travel = maxTravel(for: width)
|
|
guard travel > 0 else {
|
|
return 0
|
|
}
|
|
return min(max(abs(offset) / travel, 0), 1)
|
|
}
|
|
|
|
static func blurRadius(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
|
progress(for: offset, width: width) * 10
|
|
}
|
|
|
|
static func shouldBeginPan(
|
|
leftwardTravel: CGFloat,
|
|
verticalTravel: CGFloat,
|
|
leftwardVelocity: CGFloat,
|
|
verticalVelocity: CGFloat
|
|
) -> Bool {
|
|
guard leftwardTravel > 0 || leftwardVelocity > 0 else {
|
|
return false
|
|
}
|
|
|
|
if leftwardTravel >= horizontalActivationDistance,
|
|
leftwardTravel >= verticalTravel * directionDominanceRatio {
|
|
return true
|
|
}
|
|
|
|
return leftwardVelocity >= minimumLeftwardVelocity &&
|
|
leftwardVelocity >= verticalVelocity * directionDominanceRatio
|
|
}
|
|
|
|
static func latchReleaseDistance(for width: CGFloat) -> CGFloat {
|
|
max(latchDistance(for: width) - latchHysteresis, horizontalActivationDistance)
|
|
}
|
|
|
|
static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
|
|
let distance = abs(offset)
|
|
if isCurrentlyLatched {
|
|
return distance >= latchReleaseDistance(for: width)
|
|
}
|
|
return distance >= latchDistance(for: width)
|
|
}
|
|
}
|
|
|
|
private struct NewChatSwipePanInstaller: UIViewRepresentable {
|
|
var isEnabled: Bool
|
|
var onBegan: (CGFloat) -> Void
|
|
var onChanged: (CGFloat, CGFloat) -> Void
|
|
var onEnded: (CGFloat, CGFloat, Bool) -> Void
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator()
|
|
}
|
|
|
|
func makeUIView(context: Context) -> InstallerView {
|
|
let view = InstallerView()
|
|
view.isUserInteractionEnabled = false
|
|
view.coordinator = context.coordinator
|
|
context.coordinator.markerView = view
|
|
return view
|
|
}
|
|
|
|
func updateUIView(_ uiView: InstallerView, context: Context) {
|
|
context.coordinator.update(
|
|
isEnabled: isEnabled,
|
|
onBegan: onBegan,
|
|
onChanged: onChanged,
|
|
onEnded: onEnded
|
|
)
|
|
context.coordinator.markerView = uiView
|
|
context.coordinator.installIfPossible()
|
|
}
|
|
|
|
static func dismantleUIView(_ uiView: InstallerView, coordinator: Coordinator) {
|
|
coordinator.detach()
|
|
}
|
|
|
|
final class InstallerView: UIView {
|
|
weak var coordinator: Coordinator?
|
|
|
|
override func didMoveToWindow() {
|
|
super.didMoveToWindow()
|
|
coordinator?.markerView = self
|
|
coordinator?.installIfPossible()
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
coordinator?.configureScrollViewFailureRequirements()
|
|
}
|
|
}
|
|
|
|
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
|
|
weak var markerView: UIView?
|
|
private weak var installedWindow: UIWindow?
|
|
private let panGesture = UIPanGestureRecognizer()
|
|
private var preparedScrollRecognizers: Set<ObjectIdentifier> = []
|
|
|
|
private var isEnabled = false
|
|
private var onBegan: (CGFloat) -> Void = { _ in }
|
|
private var onChanged: (CGFloat, CGFloat) -> Void = { _, _ in }
|
|
private var onEnded: (CGFloat, CGFloat, Bool) -> Void = { _, _, _ in }
|
|
|
|
override init() {
|
|
super.init()
|
|
panGesture.addTarget(self, action: #selector(handlePan(_:)))
|
|
panGesture.cancelsTouchesInView = true
|
|
panGesture.delaysTouchesBegan = false
|
|
panGesture.delaysTouchesEnded = false
|
|
panGesture.delegate = self
|
|
}
|
|
|
|
func update(
|
|
isEnabled: Bool,
|
|
onBegan: @escaping (CGFloat) -> Void,
|
|
onChanged: @escaping (CGFloat, CGFloat) -> Void,
|
|
onEnded: @escaping (CGFloat, CGFloat, Bool) -> Void
|
|
) {
|
|
self.isEnabled = isEnabled
|
|
self.onBegan = onBegan
|
|
self.onChanged = onChanged
|
|
self.onEnded = onEnded
|
|
panGesture.isEnabled = isEnabled
|
|
configureScrollViewFailureRequirements()
|
|
}
|
|
|
|
func installIfPossible() {
|
|
guard let window = markerView?.window else {
|
|
detach()
|
|
return
|
|
}
|
|
|
|
guard installedWindow !== window else {
|
|
configureScrollViewFailureRequirements()
|
|
return
|
|
}
|
|
|
|
installedWindow?.removeGestureRecognizer(panGesture)
|
|
window.addGestureRecognizer(panGesture)
|
|
installedWindow = window
|
|
configureScrollViewFailureRequirements()
|
|
}
|
|
|
|
func detach() {
|
|
installedWindow?.removeGestureRecognizer(panGesture)
|
|
installedWindow = nil
|
|
preparedScrollRecognizers = []
|
|
}
|
|
|
|
func configureScrollViewFailureRequirements() {
|
|
guard isEnabled, let markerView, let window = markerView.window else {
|
|
return
|
|
}
|
|
|
|
let markerFrame = markerView.convert(markerView.bounds, to: window)
|
|
for scrollView in window.sybilDescendantScrollViews {
|
|
let recognizerID = ObjectIdentifier(scrollView.panGestureRecognizer)
|
|
guard !preparedScrollRecognizers.contains(recognizerID) else {
|
|
continue
|
|
}
|
|
|
|
let scrollFrame = scrollView.convert(scrollView.bounds, to: window)
|
|
if scrollFrame.intersects(markerFrame) {
|
|
scrollView.panGestureRecognizer.require(toFail: panGesture)
|
|
preparedScrollRecognizers.insert(recognizerID)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
|
|
guard let markerView else {
|
|
return
|
|
}
|
|
|
|
let width = max(markerView.bounds.width, 1)
|
|
let translationX = recognizer.translation(in: markerView).x
|
|
|
|
switch recognizer.state {
|
|
case .began:
|
|
onBegan(width)
|
|
onChanged(translationX, width)
|
|
|
|
case .changed:
|
|
onChanged(translationX, width)
|
|
|
|
case .ended:
|
|
onEnded(translationX, width, true)
|
|
|
|
case .cancelled, .failed:
|
|
onEnded(translationX, width, false)
|
|
|
|
case .possible:
|
|
break
|
|
|
|
@unknown default:
|
|
onEnded(translationX, width, false)
|
|
}
|
|
}
|
|
|
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
guard isEnabled, gestureRecognizer === panGesture, let markerView else {
|
|
return false
|
|
}
|
|
|
|
return markerView.bounds.contains(touch.location(in: markerView))
|
|
}
|
|
|
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
guard isEnabled, gestureRecognizer === panGesture, let markerView else {
|
|
return false
|
|
}
|
|
|
|
let translation = panGesture.translation(in: markerView)
|
|
let velocity = panGesture.velocity(in: markerView)
|
|
return NewChatSwipeMetrics.shouldBeginPan(
|
|
leftwardTravel: max(-translation.x, 0),
|
|
verticalTravel: abs(translation.y),
|
|
leftwardVelocity: max(-velocity.x, 0),
|
|
verticalVelocity: abs(velocity.y)
|
|
)
|
|
}
|
|
|
|
func gestureRecognizer(
|
|
_ gestureRecognizer: UIGestureRecognizer,
|
|
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
|
) -> Bool {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension UIView {
|
|
var sybilDescendantScrollViews: [UIScrollView] {
|
|
var scrollViews: [UIScrollView] = []
|
|
collectSybilScrollViews(into: &scrollViews)
|
|
return scrollViews
|
|
}
|
|
|
|
func collectSybilScrollViews(into scrollViews: inout [UIScrollView]) {
|
|
if let scrollView = self as? UIScrollView {
|
|
scrollViews.append(scrollView)
|
|
}
|
|
|
|
for subview in subviews {
|
|
subview.collectSybilScrollViews(into: &scrollViews)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SybilNavigationIcon: View {
|
|
var systemImage: String
|
|
|
|
var body: some View {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: 21, weight: .semibold, design: .rounded))
|
|
.foregroundStyle(SybilTheme.text)
|
|
.frame(width: 46, height: 46)
|
|
.contentShape(Rectangle())
|
|
.shadow(color: SybilTheme.primary.opacity(0.34), radius: 12, x: 0, y: 0)
|
|
}
|
|
}
|
|
|
|
private struct SybilNavigationFadeBackground: View {
|
|
var body: some View {
|
|
ZStack(alignment: .topLeading) {
|
|
LinearGradient(
|
|
colors: [
|
|
SybilTheme.background.opacity(1.0),
|
|
SybilTheme.background.opacity(0.90),
|
|
SybilTheme.background.opacity(0.80),
|
|
SybilTheme.background.opacity(0.80),
|
|
SybilTheme.background.opacity(0.28),
|
|
Color.clear
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
|
|
RadialGradient(
|
|
colors: [
|
|
SybilTheme.primary.opacity(0.36),
|
|
SybilTheme.primary.opacity(0.10),
|
|
Color.clear
|
|
],
|
|
center: .topLeading,
|
|
startRadius: 6,
|
|
endRadius: 210
|
|
)
|
|
.blendMode(.screen)
|
|
.offset(x: -44, y: -46)
|
|
}
|
|
.ignoresSafeArea(edges: .top)
|
|
}
|
|
}
|
|
|
|
private struct SybilChatCharacterBackdrop: View {
|
|
var isBusy: Bool
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topTrailing) {
|
|
RadialGradient(
|
|
colors: [
|
|
SybilTheme.primary.opacity(0.36),
|
|
SybilTheme.primary.opacity(0.13),
|
|
Color.clear
|
|
],
|
|
center: .center,
|
|
startRadius: 10,
|
|
endRadius: 150
|
|
)
|
|
.frame(width: 136, height: 118)
|
|
.blur(radius: 7)
|
|
.offset(x: 28, y: -24)
|
|
|
|
SybilAnimatedGIFView(resourceName: isBusy ? "character-busy" : "character-idle")
|
|
.frame(width: 172, height: 172)
|
|
.opacity(0.92)
|
|
.mask {
|
|
LinearGradient(
|
|
colors: [
|
|
Color.black,
|
|
Color.black,
|
|
Color.black.opacity(0)
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
}
|
|
.mask {
|
|
LinearGradient(
|
|
colors: [
|
|
Color.clear,
|
|
Color.black.opacity(0.74),
|
|
Color.black
|
|
],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
}
|
|
.offset(x: 8, y: 4)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 156, maxHeight: 156, alignment: .topTrailing)
|
|
.clipped()
|
|
.ignoresSafeArea(edges: .top)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
private struct SybilAnimatedGIFView: UIViewRepresentable {
|
|
var resourceName: String
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator()
|
|
}
|
|
|
|
func makeUIView(context: Context) -> UIImageView {
|
|
let imageView = SybilAnimatedUIImageView()
|
|
imageView.backgroundColor = .clear
|
|
imageView.contentMode = .scaleAspectFill
|
|
imageView.clipsToBounds = false
|
|
imageView.isOpaque = false
|
|
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
return imageView
|
|
}
|
|
|
|
func updateUIView(_ imageView: UIImageView, context: Context) {
|
|
guard context.coordinator.resourceName != resourceName else {
|
|
if !imageView.isAnimating {
|
|
imageView.startAnimating()
|
|
}
|
|
return
|
|
}
|
|
|
|
context.coordinator.resourceName = resourceName
|
|
imageView.image = SybilAnimatedGIFCache.image(named: resourceName)
|
|
imageView.startAnimating()
|
|
}
|
|
|
|
final class Coordinator {
|
|
var resourceName: String?
|
|
}
|
|
}
|
|
|
|
private final class SybilAnimatedUIImageView: UIImageView {
|
|
override var intrinsicContentSize: CGSize {
|
|
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private enum SybilAnimatedGIFCache {
|
|
private static var images: [String: UIImage] = [:]
|
|
|
|
static func image(named name: String) -> UIImage? {
|
|
if let cached = images[name] {
|
|
return cached
|
|
}
|
|
|
|
guard let image = loadImage(named: name) else {
|
|
return nil
|
|
}
|
|
|
|
images[name] = image
|
|
|
|
return image
|
|
}
|
|
|
|
private static func loadImage(named name: String) -> UIImage? {
|
|
let url = Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: "Character") ??
|
|
Bundle.main.url(forResource: name, withExtension: "gif")
|
|
|
|
guard let url,
|
|
let data = try? Data(contentsOf: url),
|
|
let source = CGImageSourceCreateWithData(data as CFData, nil)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let frameCount = CGImageSourceGetCount(source)
|
|
guard frameCount > 0 else {
|
|
return nil
|
|
}
|
|
|
|
var frames: [UIImage] = []
|
|
frames.reserveCapacity(frameCount)
|
|
var duration: TimeInterval = 0
|
|
|
|
for index in 0 ..< frameCount {
|
|
guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else {
|
|
continue
|
|
}
|
|
frames.append(UIImage(cgImage: cgImage))
|
|
duration += frameDuration(at: index, source: source)
|
|
}
|
|
|
|
guard !frames.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
if frames.count == 1 {
|
|
return frames[0]
|
|
}
|
|
|
|
return UIImage.animatedImage(with: frames, duration: max(duration, 0.1))
|
|
}
|
|
|
|
private static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
|
|
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
|
|
let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
|
|
else {
|
|
return 0.08
|
|
}
|
|
|
|
let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
|
|
let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval ?? 0.08
|
|
return max(delay, 0.03)
|
|
}
|
|
}
|
|
|
|
private struct NewChatSwipeBackdrop: View {
|
|
var progress: CGFloat
|
|
var hasLatched: Bool
|
|
|
|
private var clampedProgress: CGFloat {
|
|
min(max(progress, 0), 1)
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .trailing) {
|
|
Circle()
|
|
.fill((hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.16 + (0.18 * clampedProgress)))
|
|
.frame(width: 176, height: 176)
|
|
.blur(radius: 44)
|
|
.offset(x: 38, y: 18)
|
|
|
|
ZStack {
|
|
Circle()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: [
|
|
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.28),
|
|
SybilTheme.surface.opacity(0.78)
|
|
],
|
|
center: .topLeading,
|
|
startRadius: 8,
|
|
endRadius: 58
|
|
)
|
|
)
|
|
.overlay(
|
|
Circle()
|
|
.stroke(
|
|
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.44 + (0.24 * clampedProgress)),
|
|
lineWidth: 1
|
|
)
|
|
)
|
|
.shadow(
|
|
color: (hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.24 + (0.20 * clampedProgress)),
|
|
radius: 24,
|
|
x: 0,
|
|
y: 0
|
|
)
|
|
|
|
Circle()
|
|
.fill(
|
|
AngularGradient(
|
|
colors: [
|
|
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.20),
|
|
Color.clear,
|
|
(hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.34)
|
|
],
|
|
center: .center
|
|
)
|
|
)
|
|
.frame(width: 72, height: 72)
|
|
.blur(radius: 10)
|
|
|
|
Image(systemName: hasLatched ? "checkmark" : "plus")
|
|
.font(.system(size: 31, weight: .bold, design: .rounded))
|
|
.foregroundStyle(SybilTheme.text)
|
|
.symbolEffect(.bounce, value: hasLatched)
|
|
|
|
Image(systemName: "sparkle")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle((hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.90))
|
|
.offset(x: -26, y: -25)
|
|
}
|
|
.frame(width: 92, height: 92)
|
|
.background(
|
|
Circle()
|
|
.fill(SybilTheme.surface.opacity(0.42))
|
|
.blur(radius: 16)
|
|
)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
|
|
.opacity(clampedProgress)
|
|
.offset(x: (1 - clampedProgress) * 28)
|
|
.animation(.easeOut(duration: 0.16), value: hasLatched)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|