2024-01-02 14:23:36 +01:00
|
|
|
//Made by Lumaa
|
|
|
|
|
|
|
|
import SwiftUI
|
|
|
|
import UIKit
|
|
|
|
import PhotosUI
|
|
|
|
|
|
|
|
struct PostingView: View {
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
2024-01-28 17:50:54 +01:00
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
2024-01-02 14:23:36 +01:00
|
|
|
@Environment(AccountManager.self) private var accountManager: AccountManager
|
2024-03-09 23:17:44 +01:00
|
|
|
@Environment(AppDelegate.self) private var appDelegate: AppDelegate
|
2024-01-02 14:23:36 +01:00
|
|
|
|
2024-01-06 03:36:26 +01:00
|
|
|
public var initialString: String = ""
|
2024-01-10 17:45:41 +01:00
|
|
|
public var replyId: String? = nil
|
2024-01-22 06:48:38 +01:00
|
|
|
public var editId: String? = nil
|
2024-01-06 03:36:26 +01:00
|
|
|
|
|
|
|
@State private var viewModel: PostingView.ViewModel = PostingView.ViewModel()
|
|
|
|
|
2024-01-27 08:55:58 +01:00
|
|
|
@State private var hasKeyboard: Bool = true
|
2024-01-02 14:23:36 +01:00
|
|
|
@State private var visibility: Visibility = .pub
|
2024-03-08 14:28:54 +01:00
|
|
|
@State private var pref: UserPreferences = .defaultPreferences
|
2024-01-28 17:50:54 +01:00
|
|
|
|
|
|
|
@State private var selectingPhotos: Bool = false
|
|
|
|
@State private var mediaContainers: [MediaContainer] = []
|
2024-02-18 12:44:58 +01:00
|
|
|
@State private var mediaAttributes: [StatusData.MediaAttribute] = []
|
2024-01-28 17:50:54 +01:00
|
|
|
@State private var selectedPhotos: [PhotosPickerItem] = []
|
|
|
|
@State private var player: AVPlayer?
|
|
|
|
|
2024-03-09 19:01:29 +01:00
|
|
|
@State private var hasPoll: Bool = false
|
|
|
|
@State private var pollOptions: [String] = ["", ""]
|
|
|
|
@State private var pollExpiry: StatusData.PollData.DefaultExpiry = .oneDay
|
|
|
|
@State private var multiSelect: Bool = false
|
|
|
|
|
|
|
|
|
2024-01-26 14:52:11 +01:00
|
|
|
@State private var selectingEmoji: Bool = false
|
2024-02-18 12:44:58 +01:00
|
|
|
@State private var makingAlt: MediaContainer? = nil
|
2024-01-02 14:23:36 +01:00
|
|
|
|
2024-01-28 17:50:54 +01:00
|
|
|
@State private var loadingContent: Bool = false
|
2024-01-02 14:23:36 +01:00
|
|
|
@State private var postingStatus: Bool = false
|
|
|
|
|
2024-01-28 17:50:54 +01:00
|
|
|
init(initialString: String, replyId: String? = nil, editId: String? = nil) {
|
|
|
|
self.initialString = initialString
|
|
|
|
self.replyId = replyId
|
|
|
|
self.editId = editId
|
|
|
|
}
|
|
|
|
|
2024-01-02 14:23:36 +01:00
|
|
|
var body: some View {
|
|
|
|
if accountManager.getAccount() != nil {
|
2024-02-04 10:44:51 +01:00
|
|
|
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
|
2024-01-26 14:52:11 +01:00
|
|
|
}
|
2024-02-18 12:44:58 +01:00
|
|
|
.sheet(item: $makingAlt) { container in
|
|
|
|
AltTextView(container: container, mediaContainers: $mediaContainers, mediaAttributes: $mediaAttributes)
|
|
|
|
.presentationDetents([.height(235), .medium])
|
|
|
|
.presentationDragIndicator(.visible)
|
|
|
|
}
|
2024-01-02 14:23:36 +01:00
|
|
|
} else {
|
|
|
|
loading
|
2024-01-26 22:58:57 +01:00
|
|
|
.background(Color.appBackground)
|
2024-01-02 14:23:36 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var posting: some View {
|
2024-03-09 23:17:44 +01:00
|
|
|
ScrollView {
|
|
|
|
VStack(alignment: .leading) {
|
|
|
|
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
|
|
|
|
}
|
2024-01-02 14:23:36 +01:00
|
|
|
.multilineTextAlignment(.leading)
|
2024-03-09 23:17:44 +01:00
|
|
|
.font(.callout)
|
|
|
|
.foregroundStyle(Color(uiColor: UIColor.label))
|
|
|
|
|
|
|
|
if !mediaContainers.isEmpty {
|
|
|
|
mediasView(containers: mediaContainers)
|
|
|
|
}
|
|
|
|
|
|
|
|
if hasPoll {
|
|
|
|
editPollView
|
|
|
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
|
|
|
}
|
2024-03-09 19:01:29 +01:00
|
|
|
}
|
2024-01-02 14:23:36 +01:00
|
|
|
}
|
2024-03-09 23:17:44 +01:00
|
|
|
}
|
|
|
|
.onChange(of: selectingEmoji) { _, new in
|
|
|
|
guard new == false else { return }
|
|
|
|
viewModel.textView?.becomeFirstResponder()
|
2024-01-02 14:23:36 +01:00
|
|
|
}
|
|
|
|
}
|
2024-03-09 23:17:44 +01:00
|
|
|
}
|
|
|
|
.scrollDismissesKeyboard(.interactively)
|
|
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
|
|
.scrollIndicators(.hidden)
|
|
|
|
.frame(maxHeight: appDelegate.windowHeight - 140)
|
|
|
|
.safeAreaInset(edge: .bottom, alignment: .leading) {
|
|
|
|
VStack(alignment: .leading) {
|
|
|
|
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)
|
|
|
|
.multilineTextAlignment(.leading)
|
|
|
|
case .unlisted:
|
|
|
|
Label("status.posting.visibility.unlisted", systemImage: "magnifyingglass")
|
|
|
|
.foregroundStyle(Color.gray)
|
|
|
|
.multilineTextAlignment(.leading)
|
|
|
|
case .direct:
|
|
|
|
Label("status.posting.visibility.direct", systemImage: "paperplane")
|
|
|
|
.foregroundStyle(Color.gray)
|
|
|
|
.multilineTextAlignment(.leading)
|
|
|
|
case .priv:
|
|
|
|
Label("status.posting.visibility.private", systemImage: "lock.fill")
|
|
|
|
.foregroundStyle(Color.gray)
|
|
|
|
.multilineTextAlignment(.leading)
|
|
|
|
}
|
2024-01-02 14:23:36 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2024-03-09 23:17:44 +01:00
|
|
|
.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")
|
|
|
|
}
|
2024-01-02 14:23:36 +01:00
|
|
|
}
|
2024-03-09 23:17:44 +01:00
|
|
|
.disabled(postingStatus || viewModel.postText.length <= 0)
|
|
|
|
.buttonStyle(LargeButton(filled: true, height: 7.5, disabled: postingStatus || viewModel.postText.length <= 0))
|
2024-01-02 14:23:36 +01:00
|
|
|
}
|
2024-03-09 23:17:44 +01:00
|
|
|
|
|
|
|
editorButtons
|
2024-01-02 14:23:36 +01:00
|
|
|
}
|
|
|
|
.padding()
|
|
|
|
}
|
|
|
|
.navigationBarBackButtonHidden()
|
2024-01-26 14:10:17 +01:00
|
|
|
.navigationTitle(Text(editId == nil ? "status.posting" : "status.editing"))
|
2024-01-02 14:23:36 +01:00
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
|
|
.toolbar {
|
|
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
|
|
Button {
|
|
|
|
dismiss()
|
|
|
|
} label: {
|
|
|
|
Text("status.posting.cancel")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.onAppear {
|
2024-03-09 22:46:24 +01:00
|
|
|
self.pref = try! UserPreferences.loadAsCurrent()
|
2024-03-08 14:28:54 +01:00
|
|
|
self.visibility = pref.defaultVisibility
|
|
|
|
|
2024-01-22 06:48:38 +01:00
|
|
|
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
|
|
|
|
}
|
2024-01-02 14:23:36 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-28 17:50:54 +01:00
|
|
|
private func postText() {
|
|
|
|
Task {
|
|
|
|
if let client = accountManager.getClient() {
|
|
|
|
postingStatus = true
|
|
|
|
|
2024-02-04 10:44:51 +01:00
|
|
|
// for container in mediaContainers {
|
|
|
|
// await upload(container: container)
|
|
|
|
// }
|
2024-01-28 17:50:54 +01:00
|
|
|
|
2024-03-09 19:01:29 +01:00
|
|
|
var pollData: StatusData.PollData? = nil
|
|
|
|
if self.hasPoll {
|
|
|
|
pollData = StatusData.PollData(options: self.pollOptions, multiple: self.multiSelect, expires_in: pollExpiry.rawValue)
|
|
|
|
}
|
|
|
|
|
|
|
|
let json: StatusData = .init(status: viewModel.postText.string, visibility: visibility, inReplyToId: replyId, mediaIds: mediaContainers.compactMap { $0.mediaAttachment?.id }, poll: pollData, mediaAttributes: mediaAttributes)
|
2024-01-28 17:50:54 +01:00
|
|
|
|
|
|
|
let isEdit: Bool = editId != nil
|
|
|
|
let endp: Endpoint = isEdit ? Statuses.editStatus(id: editId!, json: json) : Statuses.postStatus(json: json)
|
|
|
|
|
2024-02-04 08:43:56 +01:00
|
|
|
let _: Status = isEdit ? try await client.put(endpoint: endp) : try await client.post(endpoint: endp)
|
2024-01-28 17:50:54 +01:00
|
|
|
|
|
|
|
postingStatus = false
|
|
|
|
HapticManager.playHaptics(haptics: Haptic.success)
|
|
|
|
dismiss()
|
2024-02-04 08:43:56 +01:00
|
|
|
|
|
|
|
if isEdit {
|
|
|
|
dismiss()
|
|
|
|
}
|
2024-01-28 17:50:54 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-03-09 19:01:29 +01:00
|
|
|
var editPollView: some View {
|
|
|
|
VStack {
|
|
|
|
ForEach(0 ..< pollOptions.count, id: \.self) { i in
|
|
|
|
let isLast: Bool = pollOptions.count - 1 == i;
|
|
|
|
|
|
|
|
RoundedRectangle(cornerRadius: 15)
|
|
|
|
.stroke(Color.gray.opacity(0.4), lineWidth: 2.5)
|
|
|
|
.frame(width: 300, height: 50)
|
|
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
|
|
.overlay {
|
|
|
|
HStack {
|
|
|
|
TextField("status.posting.poll.option-\(i + 1)", text: $pollOptions[i], axis: .horizontal)
|
|
|
|
.font(.subheadline)
|
|
|
|
.padding(.leading, 25)
|
|
|
|
.foregroundStyle(Color(uiColor: UIColor.label))
|
|
|
|
|
|
|
|
Spacer()
|
|
|
|
|
2024-03-10 17:22:13 +01:00
|
|
|
HStack(spacing: 5) {
|
2024-03-09 19:01:29 +01:00
|
|
|
Button {
|
|
|
|
withAnimation(.spring) {
|
|
|
|
self.pollOptions.append("")
|
|
|
|
}
|
|
|
|
} label: {
|
|
|
|
Image(systemName: "plus.circle.fill")
|
|
|
|
.font(.callout)
|
|
|
|
.foregroundStyle(pollOptions.count >= 4 || !isLast ? Color.gray : Color(uiColor: UIColor.label))
|
|
|
|
}
|
|
|
|
.disabled(pollOptions.count >= 4 || !isLast)
|
|
|
|
|
|
|
|
Button {
|
|
|
|
withAnimation(.spring) {
|
|
|
|
if pollOptions.count == 2 {
|
|
|
|
self.hasPoll = false
|
|
|
|
} else {
|
|
|
|
let index: Int = i
|
|
|
|
self.pollOptions.remove(at: index)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} label: {
|
|
|
|
Image(systemName: "minus.circle.fill")
|
|
|
|
.font(.callout)
|
|
|
|
.foregroundStyle(Color(uiColor: UIColor.label))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.padding(.trailing, 25)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
HStack {
|
|
|
|
Button {
|
|
|
|
withAnimation(.spring) {
|
|
|
|
multiSelect.toggle()
|
|
|
|
}
|
|
|
|
} label: {
|
|
|
|
Text(multiSelect ? LocalizedStringKey("status.posting.poll.disable-multi") : LocalizedStringKey("status.posting.poll.enable-multi"))
|
|
|
|
}
|
|
|
|
.buttonStyle(LargeButton(filled: false, height: 7.5))
|
|
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
|
|
Picker("status.posting.poll.expiry", selection: $pollExpiry) {
|
|
|
|
ForEach(StatusData.PollData.DefaultExpiry.allCases, id: \.self) { expiry in
|
|
|
|
Text(expiry.description)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.frame(width: 300)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-02 14:23:36 +01:00
|
|
|
var loading: some View {
|
|
|
|
ProgressView()
|
|
|
|
.foregroundStyle(.white)
|
|
|
|
.progressViewStyle(.circular)
|
|
|
|
}
|
|
|
|
|
2024-01-28 17:50:54 +01:00
|
|
|
private let containerWidth: CGFloat = 300
|
|
|
|
private let containerHeight: CGFloat = 450
|
|
|
|
|
|
|
|
@ViewBuilder
|
2024-02-18 12:44:58 +01:00
|
|
|
private func mediasView(containers: [MediaContainer], actions: Bool = true) -> some View {
|
2024-01-28 17:50:54 +01:00
|
|
|
ViewThatFits {
|
2024-02-18 12:44:58 +01:00
|
|
|
hMedias(containers, actions: actions)
|
2024-02-04 10:44:51 +01:00
|
|
|
.frame(maxHeight: containerHeight)
|
2024-01-28 17:50:54 +01:00
|
|
|
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
2024-02-18 12:44:58 +01:00
|
|
|
hMedias(containers, actions: actions)
|
2024-01-28 17:50:54 +01:00
|
|
|
}
|
|
|
|
.frame(maxHeight: containerHeight)
|
|
|
|
.scrollClipDisabled()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-02-04 10:44:51 +01:00
|
|
|
@ViewBuilder
|
2024-02-18 12:44:58 +01:00
|
|
|
private func hMedias(_ containers: [MediaContainer], actions: Bool) -> some View {
|
2024-02-04 10:44:51 +01:00
|
|
|
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) {
|
2024-02-18 12:44:58 +01:00
|
|
|
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)
|
|
|
|
}
|
2024-02-04 10:44:51 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.frame(maxHeight: containerHeight)
|
|
|
|
}
|
|
|
|
|
2024-01-28 17:50:54 +01:00
|
|
|
@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)
|
2024-02-04 10:44:51 +01:00
|
|
|
.frame(minWidth: mediaContainers.count == 1 ? nil : containerWidth, maxWidth: 450)
|
2024-01-28 17:50:54 +01:00
|
|
|
.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()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2024-02-04 10:44:51 +01:00
|
|
|
.frame(minWidth: mediaContainers.count == 1 ? nil : containerWidth, maxWidth: 450)
|
2024-01-28 17:50:54 +01:00
|
|
|
.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()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-02 14:23:36 +01:00
|
|
|
var editorButtons: some View {
|
2024-03-09 19:01:29 +01:00
|
|
|
//MARK: Action buttons
|
2024-01-02 14:23:36 +01:00
|
|
|
HStack(spacing: 18) {
|
2024-03-09 19:01:29 +01:00
|
|
|
if !self.hasPoll {
|
|
|
|
actionButton("photo.badge.plus") {
|
|
|
|
selectingPhotos.toggle()
|
2024-01-28 17:50:54 +01:00
|
|
|
}
|
2024-03-10 17:22:13 +01:00
|
|
|
.transition(.opacity.combined(with: .move(edge: .leading)))
|
2024-03-09 19:01:29 +01:00
|
|
|
.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)
|
|
|
|
}
|
2024-01-28 17:50:54 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2024-03-09 19:01:29 +01:00
|
|
|
.tint(Color.blue)
|
2024-01-28 17:50:54 +01:00
|
|
|
}
|
2024-01-02 14:23:36 +01:00
|
|
|
|
2024-03-09 19:01:29 +01:00
|
|
|
if mediaContainers.isEmpty || selectedPhotos.isEmpty {
|
|
|
|
actionButton("checklist") {
|
|
|
|
withAnimation(.spring) {
|
|
|
|
self.hasPoll.toggle()
|
|
|
|
}
|
2024-01-02 14:23:36 +01:00
|
|
|
}
|
2024-03-09 23:17:44 +01:00
|
|
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
2024-01-02 14:23:36 +01:00
|
|
|
}
|
2024-01-26 14:52:11 +01:00
|
|
|
|
2024-03-09 19:01:29 +01:00
|
|
|
// actionButton("number") {
|
|
|
|
// DispatchQueue.main.async {
|
|
|
|
// viewModel.append(text: "#")
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
|
2024-01-28 17:50:54 +01:00
|
|
|
let smileSf = colorScheme == .light ? "face.smiling" : "face.smiling.inverse"
|
|
|
|
actionButton(smileSf) {
|
2024-01-27 08:55:58 +01:00
|
|
|
viewModel.textView?.resignFirstResponder()
|
2024-01-26 14:52:11 +01:00
|
|
|
selectingEmoji.toggle()
|
|
|
|
}
|
2024-01-02 14:23:36 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@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)
|
|
|
|
}
|
2024-01-06 03:36:26 +01:00
|
|
|
|
2024-01-28 17:50:54 +01:00
|
|
|
//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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-02-04 10:44:51 +01:00
|
|
|
@Observable public class ViewModel: NSObject, ObservableObject {
|
2024-01-10 17:45:41 +01:00
|
|
|
init(text: String = "") {
|
|
|
|
self.postText = NSMutableAttributedString(string: text)
|
2024-01-06 03:36:26 +01:00
|
|
|
}
|
|
|
|
|
2024-01-10 17:45:41 +01:00
|
|
|
var selectedRange: NSRange {
|
|
|
|
get {
|
|
|
|
guard let textView else {
|
|
|
|
return .init(location: 0, length: 0)
|
|
|
|
}
|
|
|
|
return textView.selectedRange
|
|
|
|
}
|
|
|
|
set {
|
|
|
|
textView?.selectedRange = newValue
|
2024-01-06 03:36:26 +01:00
|
|
|
}
|
|
|
|
}
|
2024-01-10 17:45:41 +01:00
|
|
|
|
|
|
|
var postText: NSMutableAttributedString {
|
2024-01-06 03:36:26 +01:00
|
|
|
didSet {
|
2024-01-10 17:45:41 +01:00
|
|
|
let range = selectedRange
|
|
|
|
formatText()
|
|
|
|
textView?.attributedText = postText
|
|
|
|
selectedRange = range
|
2024-01-06 03:36:26 +01:00
|
|
|
}
|
|
|
|
}
|
2024-01-10 17:45:41 +01:00
|
|
|
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))
|
|
|
|
}
|
2024-01-06 03:36:26 +01:00
|
|
|
}
|
2024-01-02 14:23:36 +01:00
|
|
|
}
|
2024-01-28 17:50:54 +01:00
|
|
|
|
|
|
|
extension PostingView {
|
|
|
|
struct MediaContainer: Identifiable, Sendable {
|
|
|
|
let id: String
|
|
|
|
let image: UIImage?
|
|
|
|
let movieTransferable: MovieFileTransferable?
|
|
|
|
let gifTransferable: GifFileTranseferable?
|
|
|
|
let mediaAttachment: MediaAttachment?
|
|
|
|
let error: Error?
|
|
|
|
}
|
2024-02-18 12:44:58 +01:00
|
|
|
|
|
|
|
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 }
|
2024-02-18 18:39:22 +01:00
|
|
|
downloadImage(from: media.url ?? URL.placeholder) { image in
|
2024-02-18 12:44:58 +01:00
|
|
|
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 })
|
|
|
|
}
|
|
|
|
}
|
2024-01-28 17:50:54 +01:00
|
|
|
}
|