Bubble/Threaded/Views/PostingView.swift

796 lines
34 KiB
Swift

//Made by Lumaa
import SwiftUI
import UIKit
import PhotosUI
struct PostingView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@Environment(AccountManager.self) private var accountManager: AccountManager
public var initialString: String = ""
public var replyId: String? = nil
public var editId: String? = nil
@State private var viewModel: PostingView.ViewModel = PostingView.ViewModel()
@State private var hasKeyboard: Bool = true
@State private var visibility: Visibility = .pub
@State private var selectingPhotos: Bool = false
@State private var mediaContainers: [MediaContainer] = []
@State private var mediaAttributes: [StatusData.MediaAttribute] = []
@State private var selectedPhotos: [PhotosPickerItem] = []
@State private var player: AVPlayer?
@State private var selectingEmoji: Bool = false
@State private var makingAlt: MediaContainer? = nil
@State private var loadingContent: Bool = false
@State private var postingStatus: Bool = false
init(initialString: String, replyId: String? = nil, editId: String? = nil) {
self.initialString = initialString
self.replyId = replyId
self.editId = editId
}
var body: some View {
if accountManager.getAccount() != nil {
posting
.background(Color.appBackground)
.sheet(isPresented: $selectingEmoji) {
EmojiSelector(viewModel: viewModel)
.presentationDetents([.height(200), .medium])
.presentationDragIndicator(.visible)
.presentationBackgroundInteraction(.enabled(upThrough: .height(200))) // Allow users to move the cursor while adding emojis
}
.sheet(item: $makingAlt) { container in
AltTextView(container: container, mediaContainers: $mediaContainers, mediaAttributes: $mediaAttributes)
.presentationDetents([.height(235), .medium])
.presentationDragIndicator(.visible)
}
} else {
loading
.background(Color.appBackground)
}
}
var posting: some View {
VStack {
HStack(alignment: .top, spacing: 0) {
// MARK: Profile picture
profilePicture
VStack(alignment: .leading) {
// MARK: Status main content
VStack(alignment: .leading, spacing: 10) {
Text("@\(accountManager.forceAccount().username)")
.multilineTextAlignment(.leading)
.bold()
DynamicTextEditor($viewModel.postText, getTextView: { textView in
viewModel.textView = textView
})
.placeholder(String(localized: "status.posting.placeholder"))
.setKeyboardType(.twitter)
.onFocus {
selectingEmoji = false
}
.multilineTextAlignment(.leading)
.font(.callout)
.foregroundStyle(Color(uiColor: UIColor.label))
if !mediaContainers.isEmpty {
mediasView(containers: mediaContainers)
}
editorButtons
.padding(.vertical)
}
Spacer()
}
}
.onChange(of: selectingEmoji) { _, new in
guard new == false else { return }
viewModel.textView?.becomeFirstResponder()
}
Spacer()
HStack {
Picker("status.posting.visibility", selection: $visibility) {
ForEach(Visibility.allCases, id: \.self) { item in
HStack(alignment: .firstTextBaseline) {
switch (item) {
case .pub:
Label("status.posting.visibility.public", systemImage: "text.magnifyingglass")
.foregroundStyle(Color.gray)
case .unlisted:
Label("status.posting.visibility.unlisted", systemImage: "magnifyingglass")
.foregroundStyle(Color.gray)
case .direct:
Label("status.posting.visibility.direct", systemImage: "paperplane")
.foregroundStyle(Color.gray)
case .priv:
Label("status.posting.visibility.private", systemImage: "lock.fill")
.foregroundStyle(Color.gray)
}
Spacer()
}
}
}
.labelsHidden()
.pickerStyle(.menu)
.foregroundStyle(Color.gray)
.frame(width: 200, alignment: .leading)
.multilineTextAlignment(.leading)
Spacer()
Button {
postText()
} label: {
if postingStatus {
ProgressView()
.progressViewStyle(.circular)
.foregroundStyle(Color.appBackground)
.tint(Color.appBackground)
} else {
Text("status.posting.post")
}
}
.disabled(postingStatus || viewModel.postText.length <= 0)
.buttonStyle(LargeButton(filled: true, height: 7.5, disabled: postingStatus || viewModel.postText.length <= 0))
}
.padding()
}
.navigationBarBackButtonHidden()
.navigationTitle(Text(editId == nil ? "status.posting" : "status.editing"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Text("status.posting.cancel")
}
}
}
.onAppear {
if !initialString.isEmpty && editId == nil {
viewModel.append(text: initialString + " ") // add space for quick typing
} else {
viewModel.append(text: initialString) // editing doesn't need quick typing
}
}
}
private func postText() {
Task {
if let client = accountManager.getClient() {
postingStatus = true
// for container in mediaContainers {
// await upload(container: container)
// }
let json: StatusData = .init(status: viewModel.postText.string, visibility: visibility, inReplyToId: replyId, mediaIds: mediaContainers.compactMap { $0.mediaAttachment?.id }, mediaAttributes: mediaAttributes)
let isEdit: Bool = editId != nil
let endp: Endpoint = isEdit ? Statuses.editStatus(id: editId!, json: json) : Statuses.postStatus(json: json)
let _: Status = isEdit ? try await client.put(endpoint: endp) : try await client.post(endpoint: endp)
postingStatus = false
HapticManager.playHaptics(haptics: Haptic.success)
dismiss()
if isEdit {
dismiss()
}
}
}
}
var loading: some View {
ProgressView()
.foregroundStyle(.white)
.progressViewStyle(.circular)
}
private let containerWidth: CGFloat = 300
private let containerHeight: CGFloat = 450
@ViewBuilder
private func mediasView(containers: [MediaContainer], actions: Bool = true) -> some View {
ViewThatFits {
hMedias(containers, actions: actions)
.frame(maxHeight: containerHeight)
ScrollView(.horizontal, showsIndicators: false) {
hMedias(containers, actions: actions)
}
.frame(maxHeight: containerHeight)
.scrollClipDisabled()
}
}
@ViewBuilder
private func hMedias(_ containers: [MediaContainer], actions: Bool) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 10) {
ForEach(containers) { container in
ZStack(alignment: .topLeading) {
if let img = container.image {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(minWidth: mediaContainers.count == 1 ? nil : containerWidth, maxWidth: 450)
.overlay(
RoundedRectangle(cornerRadius: 15)
.stroke(.gray.opacity(0.3), lineWidth: 1)
)
.clipShape(.rect(cornerRadius: 15))
.contentShape(Rectangle())
} else if let attachment = container.mediaAttachment {
attchmntView(attachment: attachment)
} else if let video = container.movieTransferable {
vidView(url: video.url)
} else if let gif = container.gifTransferable {
vidView(url: gif.url)
}
}
.overlay(alignment: .topTrailing) {
if actions {
Button {
deleteAction(container: container)
} label: {
Image(systemName: "xmark")
.font(.subheadline)
.padding(10)
.background(Material.ultraThick)
.clipShape(Circle())
.padding(5)
}
}
}
.overlay(alignment: .topLeading) {
if actions && container.mediaAttachment != nil {
Button {
makingAlt = container
} label: {
Text(String("ALT"))
.font(.subheadline.smallCaps())
.padding(7.5)
.background(Material.ultraThick)
.clipShape(Capsule())
.padding(5)
}
}
}
}
}
.frame(maxHeight: containerHeight)
}
@ViewBuilder
private func attchmntView(attachment: MediaAttachment) -> some View {
GeometryReader { _ in
// Audio later because it's a lil harder
if attachment.supportedType == .image {
if let url = attachment.url {
AsyncImage(url: url) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(minWidth: mediaContainers.count == 1 ? nil : containerWidth, maxWidth: 450)
.overlay(
RoundedRectangle(cornerRadius: 15)
.stroke(.gray.opacity(0.3), lineWidth: 1)
)
.clipShape(.rect(cornerRadius: 15))
.contentShape(Rectangle())
} placeholder: {
ZStack(alignment: .center) {
Color.gray
ProgressView()
.progressViewStyle(.circular)
}
}
}
} else if attachment.supportedType == .gifv || attachment.supportedType == .video {
ZStack(alignment: .center) {
if player != nil {
NoControlsPlayerViewController(player: player!)
.overlay(
RoundedRectangle(cornerRadius: 15)
.stroke(.gray.opacity(0.3), lineWidth: 1)
)
} else {
Color.gray
ProgressView()
.progressViewStyle(.circular)
}
}
.onAppear {
if let url = attachment.url {
player = AVPlayer(url: url)
player?.audiovisualBackgroundPlaybackPolicy = .pauses
player?.isMuted = true
player?.play()
guard let player else { return }
NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
Task { @MainActor in
player.seek(to: CMTime.zero)
player.play()
}
}
}
}
.onDisappear() {
guard player != nil else { return }
player?.pause()
}
}
}
.frame(minWidth: mediaContainers.count == 1 ? nil : containerWidth, maxWidth: 450)
.clipShape(.rect(cornerRadius: 15))
.contentShape(Rectangle())
}
@ViewBuilder
private func vidView(url: URL) -> some View {
ZStack(alignment: .center) {
if player != nil {
NoControlsPlayerViewController(player: player!)
.overlay(
RoundedRectangle(cornerRadius: 15)
.stroke(.gray.opacity(0.3), lineWidth: 1)
)
} else {
Color.gray
ProgressView()
.progressViewStyle(.circular)
}
}
.onAppear {
player = AVPlayer(url: url)
player?.audiovisualBackgroundPlaybackPolicy = .pauses
player?.isMuted = true
player?.play()
guard let player else { return }
NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
Task { @MainActor in
player.seek(to: CMTime.zero)
player.play()
}
}
}
.onDisappear() {
guard player != nil else { return }
player?.pause()
}
}
var editorButtons: some View {
HStack(spacing: 18) {
actionButton("photo.badge.plus") {
selectingPhotos.toggle()
}
.photosPicker(isPresented: $selectingPhotos, selection: $selectedPhotos, maxSelectionCount: 4, matching: .any(of: [.images, .videos]), photoLibrary: .shared())
.onChange(of: selectedPhotos) { oldValue, _ in
if selectedPhotos.count > 4 {
selectedPhotos = selectedPhotos.prefix(4).map { $0 }
}
let removedIDs = oldValue
.filter { !selectedPhotos.contains($0) }
.compactMap(\.itemIdentifier)
mediaContainers.removeAll { removedIDs.contains($0.id) }
let newPickerItems = selectedPhotos.filter { !oldValue.contains($0) }
if !newPickerItems.isEmpty {
loadingContent = true
Task {
for item in newPickerItems {
initImage(for: item)
}
}
}
}
.tint(Color.blue)
actionButton("number") {
DispatchQueue.main.async {
viewModel.append(text: "#")
}
}
let smileSf = colorScheme == .light ? "face.smiling" : "face.smiling.inverse"
actionButton(smileSf) {
viewModel.textView?.resignFirstResponder()
selectingEmoji.toggle()
}
}
}
@ViewBuilder
func actionButton(_ image: String, action: @escaping () -> Void) -> some View {
Button {
action()
} label: {
Image(systemName: image)
.font(.callout)
}
.tint(Color.gray)
}
@ViewBuilder
func asyncActionButton(_ image: String, action: @escaping () async -> Void) -> some View {
Button {
Task {
await action()
}
} label: {
Image(systemName: image)
.font(.callout)
}
.tint(Color.gray)
}
var profilePicture: some View {
OnlineImage(url: accountManager.forceAccount().avatar, size: 50, useNuke: true)
.frame(width: 40, height: 40)
.padding(.horizontal)
.clipShape(.circle)
}
//MARK: - Image manipulations
private func indexOf(container: MediaContainer) -> Int? {
mediaContainers.firstIndex(where: { $0.id == container.id })
}
func initImage(for pickerItem: PhotosPickerItem) {
Task(priority: .high) {
if let container = await makeMediaContainer(from: pickerItem) {
self.mediaContainers.append(container)
await upload(container: container)
self.loadingContent = false
}
}
}
func initImage(for container: MediaContainer) {
Task(priority: .high) {
self.mediaContainers.append(container)
self.loadingContent = false
}
}
nonisolated func makeMediaContainer(from pickerItem: PhotosPickerItem) async -> PostingView.MediaContainer? {
await withTaskGroup(of: MediaContainer?.self, returning: MediaContainer?.self) { taskGroup in
taskGroup.addTask(priority: .high) { await Self.makeImageContainer(from: pickerItem) }
taskGroup.addTask(priority: .high) { await Self.makeGifContainer(from: pickerItem) }
taskGroup.addTask(priority: .high) { await Self.makeMovieContainer(from: pickerItem) }
for await container in taskGroup {
if let container {
taskGroup.cancelAll()
return container
}
}
return nil
}
}
private static func makeGifContainer(from pickerItem: PhotosPickerItem) async -> PostingView.MediaContainer? {
guard let gifFile = try? await pickerItem.loadTransferable(type: GifFileTranseferable.self) else { return nil }
return PostingView.MediaContainer(id: pickerItem.itemIdentifier ?? UUID().uuidString, image: nil, movieTransferable: nil, gifTransferable: gifFile, mediaAttachment: nil, error: nil)
}
private static func makeMovieContainer(from pickerItem: PhotosPickerItem) async -> PostingView.MediaContainer? {
guard let movieFile = try? await pickerItem.loadTransferable(type: MovieFileTransferable.self) else { return nil }
return PostingView.MediaContainer(id: pickerItem.itemIdentifier ?? UUID().uuidString, image: nil, movieTransferable: movieFile, gifTransferable: nil, mediaAttachment: nil, error: nil)
}
private static func makeImageContainer(from pickerItem: PhotosPickerItem) async -> PostingView.MediaContainer? {
guard let imageFile = try? await pickerItem.loadTransferable(type: ImageFileTranseferable.self) else { return nil }
let compressor = Compressor()
guard let compressedData = await compressor.compressImageFrom(url: imageFile.url),
let image = UIImage(data: compressedData)
else { return nil }
return PostingView.MediaContainer(id: pickerItem.itemIdentifier ?? UUID().uuidString, image: image, movieTransferable: nil, gifTransferable: nil, mediaAttachment: nil, error: nil)
}
func upload(container: PostingView.MediaContainer) async {
if let index = indexOf(container: container) {
let originalContainer = mediaContainers[index]
guard originalContainer.mediaAttachment == nil else { return }
let newContainer = MediaContainer(id: originalContainer.id, image: originalContainer.image, movieTransferable: originalContainer.movieTransferable, gifTransferable: nil, mediaAttachment: nil, error: nil)
mediaContainers[index] = newContainer
do {
let compressor = Compressor()
if let image = originalContainer.image {
let imageData = try await compressor.compressImageForUpload(image)
let uploadedMedia = try await uploadMedia(data: imageData, mimeType: "image/jpeg")
if let index = indexOf(container: newContainer) {
mediaContainers[index] = PostingView.MediaContainer(id: originalContainer.id, image: nil, movieTransferable: nil, gifTransferable: nil, mediaAttachment: uploadedMedia, error: nil)
}
if let uploadedMedia, uploadedMedia.url == nil {
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
}
} else if let videoURL = originalContainer.movieTransferable?.url,
let compressedVideoURL = await compressor.compressVideo(videoURL),
let data = try? Data(contentsOf: compressedVideoURL)
{
let uploadedMedia = try await uploadMedia(data: data, mimeType: compressedVideoURL.mimeType())
if let index = indexOf(container: newContainer) {
mediaContainers[index] = PostingView.MediaContainer(id: originalContainer.id, image: nil, movieTransferable: originalContainer.movieTransferable, gifTransferable: nil, mediaAttachment: uploadedMedia, error: nil)
}
if let uploadedMedia, uploadedMedia.url == nil {
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
}
} else if let gifData = originalContainer.gifTransferable?.data {
let uploadedMedia = try await uploadMedia(data: gifData, mimeType: "image/gif")
if let index = indexOf(container: newContainer) {
mediaContainers[index] = PostingView.MediaContainer(id: originalContainer.id, image: nil, movieTransferable: nil, gifTransferable: originalContainer.gifTransferable, mediaAttachment: uploadedMedia, error: nil)
}
if let uploadedMedia, uploadedMedia.url == nil {
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
}
}
} catch {
if let index = indexOf(container: newContainer) {
mediaContainers[index] = PostingView.MediaContainer(id: originalContainer.id, image: originalContainer.image, movieTransferable: nil, gifTransferable: nil, mediaAttachment: nil, error: error)
}
}
}
}
private func scheduleAsyncMediaRefresh(mediaAttachement: MediaAttachment) {
Task {
repeat {
if let client = accountManager.getClient(),
let index = mediaContainers.firstIndex(where: { $0.mediaAttachment?.id == mediaAttachement.id })
{
guard mediaContainers[index].mediaAttachment?.url == nil else {
return
}
do {
let newAttachement: MediaAttachment = try await client.get(endpoint: Media.media(id: mediaAttachement.id, json: nil))
if newAttachement.url != nil {
let oldContainer = mediaContainers[index]
mediaContainers[index] = MediaContainer(id: mediaAttachement.id, image: oldContainer.image, movieTransferable: oldContainer.movieTransferable, gifTransferable: oldContainer.gifTransferable, mediaAttachment: newAttachement, error: nil)
}
} catch {
print(error.localizedDescription)
}
}
try? await Task.sleep(for: .seconds(5))
} while !Task.isCancelled
}
}
private func uploadMedia(data: Data, mimeType: String) async throws -> MediaAttachment? {
guard let client = accountManager.getClient() else { return nil }
return try await client.mediaUpload(endpoint: Media.medias, version: .v2, method: "POST", mimeType: mimeType, filename: "file", data: data)
}
/// Removes an image from the editor
private func deleteAction(container: MediaContainer) {
selectedPhotos.removeAll(where: {
if let id = $0.itemIdentifier {
return id == container.id
}
return false
})
mediaContainers.removeAll {
$0.id == container.id
}
}
@Observable public class ViewModel: NSObject, ObservableObject {
init(text: String = "") {
self.postText = NSMutableAttributedString(string: text)
}
var selectedRange: NSRange {
get {
guard let textView else {
return .init(location: 0, length: 0)
}
return textView.selectedRange
}
set {
textView?.selectedRange = newValue
}
}
var postText: NSMutableAttributedString {
didSet {
let range = selectedRange
formatText()
textView?.attributedText = postText
selectedRange = range
}
}
var textView: UITextView?
func append(text: String) {
let string = postText
string.mutableString.insert(text, at: selectedRange.location)
postText = string
selectedRange = NSRange(location: selectedRange.location + text.utf16.count, length: 0)
}
func formatText() {
postText.addAttributes([.foregroundColor : UIColor.label, .font: UIFont.preferredFont(forTextStyle: .callout), .backgroundColor: UIColor.clear, .underlineColor: UIColor.clear], range: NSMakeRange(0, postText.string.utf16.count))
}
}
}
extension PostingView {
struct MediaContainer: Identifiable, Sendable {
let id: String
let image: UIImage?
let movieTransferable: MovieFileTransferable?
let gifTransferable: GifFileTranseferable?
let mediaAttachment: MediaAttachment?
let error: Error?
}
struct AltTextView: View {
@Environment(AccountManager.self) private var accountManager: AccountManager
@Environment(HuggingFace.self) private var huggingFace: HuggingFace
@Environment(\.dismiss) private var dismiss
var container: MediaContainer
@Binding var mediaContainers: [MediaContainer]
@Binding var mediaAttributes: [StatusData.MediaAttribute]
@State private var tasking: Bool = false
@State private var applying: Bool = false
@State private var alt: String = ""
@FocusState private var altFocused: Bool
var body: some View {
NavigationStack {
List {
TextField(String(""), text: $alt, prompt: Text("posting.alt.prompt"), axis: .vertical)
.labelsHidden()
.keyboardType(.asciiCapable)
.focused($altFocused)
Button {
tasking = true
let img = container.image
if img == nil, let media = container.mediaAttachment {
guard media.supportedType == .image else { return }
downloadImage(from: media.url ?? URL.placeholder) { image in
if let uiimage = image {
alt = huggingFace.altGeneration(image: uiimage) ?? ""
tasking = false
} else {
print("Couldn't download image")
}
}
} else {
alt = huggingFace.altGeneration(image: img!) ?? ""
tasking = false
}
} label: {
if !tasking {
Label("posting.alt.generate", systemImage: "printer")
.foregroundStyle(Color.blue)
} else {
ProgressView()
.progressViewStyle(.circular)
.foregroundStyle(Color(uiColor: UIColor.label))
}
}
.tint(tasking ? Color(uiColor: UIColor.label) : Color.blue)
.disabled(tasking)
}
.navigationTitle(Text("posting.alt.header"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
applying = true
if let mediaAttachment = container.mediaAttachment {
if let str = mediaAttachment.description, !str.isEmpty {
Task {
await editDescription(container: container, description: alt)
applying = false
dismiss()
}
} else {
Task {
await addDescription(container: container, description: alt)
applying = false
dismiss()
}
}
}
} label: {
if applying {
ProgressView()
.progressViewStyle(.circular)
} else {
Text("posting.alt.apply")
}
}
}
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Text("posting.alt.cancel")
}
}
}
.onAppear {
altFocused = true
}
}
}
private func downloadImage(from url: URL, completion: @escaping (UIImage?) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data, error == nil else {
completion(nil)
return
}
DispatchQueue.main.async {
completion(UIImage(data: data))
}
}.resume()
}
private func addDescription(container: MediaContainer, description: String) async {
guard let client = accountManager.getClient(), let attachment = container.mediaAttachment else { return }
if let index = indexOf(container: container) {
do {
let media: MediaAttachment = try await client.put(endpoint: Media.media(id: attachment.id,
json: .init(description: description)))
mediaContainers[index] = MediaContainer(
id: container.id,
image: nil,
movieTransferable: nil,
gifTransferable: nil,
mediaAttachment: media,
error: nil
)
} catch {}
}
}
private func editDescription(container: MediaContainer, description: String) async {
guard let attachment = container.mediaAttachment else { return }
if indexOf(container: container) != nil {
mediaAttributes.append(StatusData.MediaAttribute(id: attachment.id, description: description, thumbnail: nil, focus: nil))
}
}
private func indexOf(container: MediaContainer) -> Int? {
mediaContainers.firstIndex(where: { $0.id == container.id })
}
}
}