Attachments, settings, begin of quotes...

This commit is contained in:
Lumaa 2024-01-04 22:19:35 +01:00
parent 7aae807bd8
commit 951b60a846
16 changed files with 929 additions and 82 deletions

View File

@ -25,11 +25,17 @@
B9842C142B2F310C00D9F3C1 /* FetchTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9842C132B2F310C00D9F3C1 /* FetchTimeline.swift */; };
B9842C162B2F363600D9F3C1 /* TimelineFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9842C152B2F363600D9F3C1 /* TimelineFilter.swift */; };
B9842C182B2F36F500D9F3C1 /* AccountsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9842C172B2F36F500D9F3C1 /* AccountsList.swift */; };
B98BC7472B46CE6300595441 /* PostDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B98BC7462B46CE6300595441 /* PostDetailsView.swift */; };
B98BC7492B46CEDA00595441 /* AppearenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B98BC7482B46CEDA00595441 /* AppearenceView.swift */; };
B98BC74B2B46CF0400595441 /* ListStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B98BC74A2B46CF0400595441 /* ListStyle.swift */; };
B98BC74D2B46CFCE00595441 /* UserPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = B98BC74C2B46CFCE00595441 /* UserPreferences.swift */; };
B9B63B212B442D1500BBC82D /* DynamicTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B63B202B442D1500BBC82D /* DynamicTextEditor.swift */; };
B9B63B232B447B8000BBC82D /* PostCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B63B222B447B8000BBC82D /* PostCardView.swift */; };
B9B63B252B44997400BBC82D /* QuotePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B63B242B44997400BBC82D /* QuotePostView.swift */; };
B9B63B272B449CDC00BBC82D /* SearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B63B262B449CDC00BBC82D /* SearchResults.swift */; };
B9CC45B82B40A2D6001E4FA5 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9CC45B72B40A2D6001E4FA5 /* AboutView.swift */; };
B9EBE8562B47256900FB594D /* PostAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EBE8552B47256900FB594D /* PostAttachment.swift */; };
B9EBE8582B474FD600FB594D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EBE8572B474FD600FB594D /* AppDelegate.swift */; };
B9FB945B2B2DEECE00D81C07 /* ThreadedApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB945A2B2DEECE00D81C07 /* ThreadedApp.swift */; };
B9FB945D2B2DEECE00D81C07 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB945C2B2DEECE00D81C07 /* ContentView.swift */; };
B9FB94612B2DEECF00D81C07 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B9FB94602B2DEECF00D81C07 /* Assets.xcassets */; };
@ -99,12 +105,18 @@
B9842C132B2F310C00D9F3C1 /* FetchTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchTimeline.swift; sourceTree = "<group>"; };
B9842C152B2F363600D9F3C1 /* TimelineFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineFilter.swift; sourceTree = "<group>"; };
B9842C172B2F36F500D9F3C1 /* AccountsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsList.swift; sourceTree = "<group>"; };
B98BC7462B46CE6300595441 /* PostDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostDetailsView.swift; sourceTree = "<group>"; };
B98BC7482B46CEDA00595441 /* AppearenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearenceView.swift; sourceTree = "<group>"; };
B98BC74A2B46CF0400595441 /* ListStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListStyle.swift; sourceTree = "<group>"; };
B98BC74C2B46CFCE00595441 /* UserPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferences.swift; sourceTree = "<group>"; };
B9B63B202B442D1500BBC82D /* DynamicTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicTextEditor.swift; sourceTree = "<group>"; };
B9B63B222B447B8000BBC82D /* PostCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCardView.swift; sourceTree = "<group>"; };
B9B63B242B44997400BBC82D /* QuotePostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotePostView.swift; sourceTree = "<group>"; };
B9B63B262B449CDC00BBC82D /* SearchResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResults.swift; sourceTree = "<group>"; };
B9CC45B72B40A2D6001E4FA5 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
B9CC45B92B40AA1E001E4FA5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
B9EBE8552B47256900FB594D /* PostAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostAttachment.swift; sourceTree = "<group>"; };
B9EBE8572B474FD600FB594D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
B9FB94572B2DEECE00D81C07 /* Threaded.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Threaded.app; sourceTree = BUILT_PRODUCTS_DIR; };
B9FB945A2B2DEECE00D81C07 /* ThreadedApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadedApp.swift; sourceTree = "<group>"; };
B9FB945C2B2DEECE00D81C07 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@ -190,6 +202,7 @@
children = (
B9FB94A02B2EF23100D81C07 /* Info.plist */,
B9FB945A2B2DEECE00D81C07 /* ThreadedApp.swift */,
B9EBE8572B474FD600FB594D /* AppDelegate.swift */,
B9FB946E2B2DF3BB00D81C07 /* Components */,
B9FB946D2B2DF3B800D81C07 /* Views */,
B9FB946C2B2DF3A600D81C07 /* Data */,
@ -214,6 +227,7 @@
B9FB94BD2B2F038D00D81C07 /* Accounts */,
B9FB946F2B2DF3CD00D81C07 /* Navigator.swift */,
B9FB94A12B2EF24A00D81C07 /* AppInfo.swift */,
B98BC74C2B46CFCE00595441 /* UserPreferences.swift */,
B9FB94BB2B2F035500D81C07 /* Tag.swift */,
B9FB94802B2E1FEF00D81C07 /* HTMLString.swift */,
B9FB94872B2E223E00D81C07 /* Emoji.swift */,
@ -240,6 +254,7 @@
B9842C112B2F2A5800D9F3C1 /* TimelineView.swift */,
B97BCE252B3DE5A10044756D /* AccountView.swift */,
B93B677B2B433A6E000892E9 /* PostingView.swift */,
B98BC7462B46CE6300595441 /* PostDetailsView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -256,6 +271,8 @@
B9B63B202B442D1500BBC82D /* DynamicTextEditor.swift */,
B9B63B222B447B8000BBC82D /* PostCardView.swift */,
B9B63B242B44997400BBC82D /* QuotePostView.swift */,
B98BC74A2B46CF0400595441 /* ListStyle.swift */,
B9EBE8552B47256900FB594D /* PostAttachment.swift */,
);
path = Components;
sourceTree = "<group>";
@ -266,6 +283,7 @@
B9FB94912B2E35D000D81C07 /* SettingsView.swift */,
B9FB94962B2EDABF00D81C07 /* PrivacyView.swift */,
B9CC45B72B40A2D6001E4FA5 /* AboutView.swift */,
B98BC7482B46CEDA00595441 /* AppearenceView.swift */,
);
path = Settings;
sourceTree = "<group>";
@ -417,6 +435,7 @@
buildActionMask = 2147483647;
files = (
B9FB94922B2E35D000D81C07 /* SettingsView.swift in Sources */,
B98BC7472B46CE6300595441 /* PostDetailsView.swift in Sources */,
B9CC45B82B40A2D6001E4FA5 /* AboutView.swift in Sources */,
B9FB94882B2E223E00D81C07 /* Emoji.swift in Sources */,
B93B67782B42E8F0000892E9 /* TextEmoji.swift in Sources */,
@ -424,6 +443,7 @@
B9FB947D2B2E19E300D81C07 /* AccountManager.swift in Sources */,
B9FB945D2B2DEECE00D81C07 /* ContentView.swift in Sources */,
B9842C0E2B2F21B700D9F3C1 /* CompactPostView.swift in Sources */,
B98BC7492B46CEDA00595441 /* AppearenceView.swift in Sources */,
B9FB94992B2EEB9400D81C07 /* AddInstanceView.swift in Sources */,
B9FB94972B2EDABF00D81C07 /* PrivacyView.swift in Sources */,
B9842C142B2F310C00D9F3C1 /* FetchTimeline.swift in Sources */,
@ -437,12 +457,15 @@
B9FB945B2B2DEECE00D81C07 /* ThreadedApp.swift in Sources */,
B9FB94862B2E211200D81C07 /* Account+Elms.swift in Sources */,
B9FB94BC2B2F035500D81C07 /* Tag.swift in Sources */,
B98BC74B2B46CF0400595441 /* ListStyle.swift in Sources */,
B9FB94812B2E1FEF00D81C07 /* HTMLString.swift in Sources */,
B9FB947F2B2E1D5F00D81C07 /* Account.swift in Sources */,
B9842C122B2F2A5800D9F3C1 /* TimelineView.swift in Sources */,
B9FB948C2B2E232300D81C07 /* OnlineImage.swift in Sources */,
B9FB94742B2DF6A100D81C07 /* ButtonStyles.swift in Sources */,
B9FB94702B2DF3CD00D81C07 /* Navigator.swift in Sources */,
B9EBE8562B47256900FB594D /* PostAttachment.swift in Sources */,
B9EBE8582B474FD600FB594D /* AppDelegate.swift in Sources */,
B93B677C2B433A6E000892E9 /* PostingView.swift in Sources */,
B97BCE262B3DE5A10044756D /* AccountView.swift in Sources */,
B9B63B272B449CDC00BBC82D /* SearchResults.swift in Sources */,
@ -452,6 +475,7 @@
B9FB949F2B2EF0F200D81C07 /* MastodonRequest.swift in Sources */,
B9842C182B2F36F500D9F3C1 /* AccountsList.swift in Sources */,
B9FB948E2B2E28E800D81C07 /* ShareableImage.swift in Sources */,
B98BC74D2B46CFCE00595441 /* UserPreferences.swift in Sources */,
B9FB94A22B2EF24A00D81C07 /* AppInfo.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

@ -1,13 +1,48 @@
//Made by Lumaa
import SwiftUI
import UIKit
struct AppDelegate: View {
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
@Observable
public class AppDelegate: NSObject, UIWindowSceneDelegate, Sendable, UIApplicationDelegate {
public var window: UIWindow?
public private(set) var windowWidth: CGFloat = UIScreen.main.bounds.size.width
public private(set) var windowHeight: CGFloat = UIScreen.main.bounds.size.height
public func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
window = windowScene.keyWindow
}
override public init() {
super.init()
windowWidth = window?.bounds.size.width ?? UIScreen.main.bounds.size.width
windowHeight = window?.bounds.size.height ?? UIScreen.main.bounds.size.height
Self.observedSceneDelegate.insert(self)
_ = Self.observer // just for activating the lazy static property
}
deinit {
Task { @MainActor in
Self.observedSceneDelegate.remove(self)
}
}
private static var observedSceneDelegate: Set<AppDelegate> = []
private static let observer = Task {
while true {
try? await Task.sleep(for: .seconds(0.1))
for delegate in observedSceneDelegate {
let newWidth = delegate.window?.bounds.size.width ?? UIScreen.main.bounds.size.width
if delegate.windowWidth != newWidth {
delegate.windowWidth = newWidth
}
let newHeight = delegate.window?.bounds.size.height ?? UIScreen.main.bounds.size.height
if delegate.windowHeight != newHeight {
delegate.windowHeight = newHeight
}
}
}
}
}
#Preview {
AppDelegate()
}

View File

@ -6,8 +6,10 @@ struct CompactPostView: View {
@Environment(AccountManager.self) private var accountManager: AccountManager
var status: Status
@ObservedObject var navigator: Navigator
var pinned: Bool = false
@State private var preferences: UserPreferences = .defaultPreferences
@State private var initialLike: Bool = false
@State private var isLiked: Bool = false
@State private var isReposted: Bool = false
@ -36,13 +38,18 @@ struct CompactPostView: View {
.padding(.bottom, 3)
}
.onAppear {
do {
preferences = try UserPreferences.loadAsCurrent() ?? UserPreferences.defaultPreferences
} catch {
print(error)
}
isLiked = status.reblog != nil ? status.reblog!.favourited ?? false : status.favourited ?? false
initialLike = isLiked
isReposted = status.reblog != nil ? status.reblog!.reblogged ?? false : status.reblogged ?? false
let likeCount: Int = status.favouritesCount - (initialLike ? 1 : 0)
let incrLike: Int = isLiked ? 1 : 0
print("original: \(status.favouritesCount)\nmin1: \(likeCount)\nincr1: \(likeCount + incrLike)")
// let likeCount: Int = status.favouritesCount - (initialLike ? 1 : 0)
// let incrLike: Int = isLiked ? 1 : 0
// print("original: \(status.favouritesCount)\nmin1: \(likeCount)\nincr1: \(likeCount + incrLike)")
}
.task {
await loadEmbeddedStatus()
@ -81,7 +88,7 @@ struct CompactPostView: View {
func statusPost(_ status: AnyStatus) -> some View {
HStack(alignment: .top, spacing: 0) {
// MARK: Profile picture
if status.repliesCount > 0 {
if status.repliesCount > 0 && preferences.experimental.replySymbol {
VStack {
profilePicture
.onTapGesture {
@ -130,10 +137,26 @@ struct CompactPostView: View {
.font(.callout)
}
if status.card != nil {
if status.card != nil && status.mediaAttachments.isEmpty {
PostCardView(card: status.card!)
}
if !status.mediaAttachments.isEmpty {
if status.mediaAttachments.count > 1 {
ScrollView(.horizontal) {
HStack(alignment: .firstTextBaseline, spacing: 5) {
ForEach(status.mediaAttachments) { attachment in
PostAttachment(attachment: attachment)
}
}
}
.scrollIndicators(.hidden)
.scrollClipDisabled()
} else {
PostAttachment(attachment: status.mediaAttachments.first!)
}
}
// if hasQuote {
// if quoteStatus != nil {
// QuotePostView(status: quoteStatus!)
@ -328,14 +351,3 @@ struct CompactPostView: View {
.tint(Color(uiColor: UIColor.label))
}
}
#Preview {
ScrollView {
VStack {
ForEach(Status.placeholders()) { status in
CompactPostView(status: status, navigator: Navigator())
.environment(Client.init(server: AppInfo.defaultServer))
}
}
}
}

View File

@ -2,12 +2,17 @@
import SwiftUI
struct ListStyle: View {
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
extension View {
func listThreaded() -> some View {
self
.scrollContentBackground(.hidden)
.tint(Color.white)
.background(Color.appBackground)
.listStyle(.inset)
}
func listRowThreaded() -> some View {
self
.listRowSeparator(.hidden)
.listRowBackground(Color.appBackground)
}
}
#Preview {
ListStyle()
}

View File

@ -6,7 +6,8 @@ import NukeUI
struct OnlineImage: View {
var url: URL?
var size: CGFloat = 500
var size: CGFloat? = 500
var maxSize: CGFloat? = 500
var priority: ImageRequest.Priority = .normal
var useNuke: Bool = true
@ -18,6 +19,7 @@ struct OnlineImage: View {
.resizable()
.scaledToFit()
.aspectRatio(1.0, contentMode: .fit)
.frame(idealWidth: size, maxWidth: maxSize)
} else if state.error != nil {
ContentUnavailableView("error.loading-image", systemImage: "rectangle.slash")
} else {
@ -30,14 +32,14 @@ struct OnlineImage: View {
}
}
.priority(priority)
.processors([.resize(width: size)])
.processors([.resize(width: size ?? 500)])
} else {
AsyncImage(url: url) { element in
element
.resizable()
.scaledToFit()
.aspectRatio(1.0, contentMode: .fit)
.frame(width: size)
.frame(minWidth: size, maxWidth: maxSize, alignment: .topLeading)
} placeholder: {
Rectangle()
.fill(Color.gray)
@ -72,6 +74,40 @@ struct OnlineImage: View {
self.useNuke = true
}
init(url: URL? = nil, maxSize: CGFloat, useNuke: Bool) {
self.url = url
self.maxSize = maxSize
self.size = nil
self.priority = .normal
self.useNuke = useNuke
}
/// Creates a new OnlineImage using Nuke, using the selected priority
init(url: URL? = nil, maxSize: CGFloat, priority: ImageRequest.Priority) {
self.url = url
self.maxSize = maxSize
self.size = nil
self.priority = priority
self.useNuke = true
}
init(url: URL? = nil, size: CGFloat, maxSize: CGFloat, useNuke: Bool) {
self.url = url
self.maxSize = maxSize
self.size = size
self.priority = .normal
self.useNuke = useNuke
}
/// Creates a new OnlineImage using Nuke, using the selected priority
init(url: URL? = nil, size: CGFloat, maxSize: CGFloat, priority: ImageRequest.Priority) {
self.url = url
self.maxSize = maxSize
self.size = size
self.priority = priority
self.useNuke = true
}
/// Change the priority of the Nuke OnlineImage
mutating func setPriority(_ priority: ImageRequest.Priority) {
guard self.useNuke == true else { return }

View File

@ -1,13 +1,158 @@
//Made by Lumaa
import SwiftUI
import UIKit
import AVKit
struct PostAttachment: View {
@Environment(AppDelegate.self) private var appDelegate: AppDelegate
var attachment: MediaAttachment
@State private var player: AVPlayer?
var appLayoutWidth: CGFloat = 10
var availableWidth: CGFloat {
appDelegate.windowWidth * 0.8
}
var availableHeight: CGFloat {
appDelegate.windowHeight
}
private let imageMaxHeight: CGFloat = 300
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
let mediaSize: CGSize = size(for: attachment) ?? .init(width: imageMaxHeight, height: imageMaxHeight)
let newSize = imageSize(from: mediaSize)
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(width: newSize.width, height: newSize.height)
.overlay(
RoundedRectangle(cornerRadius: 15)
.stroke(.gray.opacity(0.3), lineWidth: 1)
)
} placeholder: {
ZStack(alignment: .center) {
Color.gray
ProgressView()
.progressViewStyle(.circular)
}
}
}
} else if attachment.supportedType == .gifv {
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()
}
}
}
}
} else if attachment.supportedType == .video {
ZStack {
if player != nil {
VideoPlayer(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 = false
player?.play()
}
}
}
}
.frame(width: newSize.width, height: newSize.height)
.clipped()
.clipShape(.rect(cornerRadius: 15))
.contentShape(Rectangle())
}
private func size(for media: MediaAttachment) -> CGSize? {
guard let width = media.meta?.original?.width,
let height = media.meta?.original?.height
else { return nil }
return .init(width: CGFloat(width), height: CGFloat(height))
}
private func imageSize(from: CGSize) -> CGSize {
let boxWidth = availableWidth - appLayoutWidth
let boxHeight = availableHeight * 0.8 // use only 80% of window height to leave room for text
if from.width <= boxWidth, from.height <= boxHeight {
// intrinsic size of image fits just fine
return from
}
// shrink image proportionally to fit inside the box
let xRatio = boxWidth / from.width
let yRatio = boxHeight / from.height
if xRatio < yRatio {
return .init(width: boxWidth, height: from.height * xRatio)
} else {
return .init(width: from.width * yRatio, height: boxHeight)
}
}
}
#Preview {
PostAttachment()
class NoControlsAVPlayerViewController: AVPlayerViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.showsPlaybackControls = false
}
}
// Create a UIViewRepresentable for the customized AVPlayerViewController
struct NoControlsPlayerViewController: UIViewControllerRepresentable {
let player: AVPlayer
func updateUIViewController(_ uiViewController: NoControlsAVPlayerViewController, context: Context) {
// update
}
func makeUIViewController(context: Context) -> NoControlsAVPlayerViewController {
let customPlayerVC = NoControlsAVPlayerViewController()
customPlayerVC.player = player // Set the AVPlayer
return customPlayerVC
}
}

View File

@ -13,7 +13,6 @@ struct QuotePostView: View {
.frame(width: 250)
.padding(.horizontal, 10)
.clipShape(.rect(cornerRadius: 15))
.fixedSize(horizontal: false, vertical: true)
.overlay(
RoundedRectangle(cornerRadius: 15)
.stroke(.gray.opacity(0.3), lineWidth: 1)

View File

@ -68,6 +68,7 @@ public enum SheetDestination: Identifiable {
public enum RouterDestination: Hashable {
case settings
case privacy
case appearence
case account(acc: Account)
case about
}
@ -80,6 +81,8 @@ extension View {
SettingsView(navigator: navigator)
case .privacy:
PrivacyView()
case .appearence:
AppearenceView()
case .account(let acc):
AccountView(account: acc, navigator: navigator)
case .about:

View File

@ -1,3 +1,76 @@
//Made by Lumaa
import Foundation
@Observable
class UserPreferences: Codable, ObservableObject {
private static let saveKey: String = "threaded-preferences.user"
public static let defaultPreferences: UserPreferences = .init()
// Final
var displayedName: DisplayedName = .username
var profilePictureShape: ProfilePictureShape = .circle
// Experimental
var showExperimental: Bool = false
var experimental: UserPreferences.Experimental
init(displayedName: DisplayedName = .username, profilePictureShape: ProfilePictureShape = .circle, showExperimental: Bool = false, experimental: UserPreferences.Experimental = .init()) {
self.displayedName = displayedName
self.profilePictureShape = profilePictureShape
self.showExperimental = showExperimental
self.experimental = experimental
}
@Observable
class Experimental: Codable, ObservableObject {
private static let saveKey: String = "threaded-preferences.experimental"
var replySymbol: Bool = false
init(replySymbol: Bool = false) {
self.replySymbol = replySymbol
}
func saveAsCurrent() throws {
let encoder = JSONEncoder()
let json = try encoder.encode(self)
UserDefaults.standard.setValue(json, forKey: UserPreferences.Experimental.saveKey)
}
static func loadAsCurrent() throws -> UserPreferences.Experimental? {
let decoder = JSONDecoder()
if let data = UserDefaults.standard.data(forKey: UserPreferences.Experimental.saveKey) {
let exp = try decoder.decode(UserPreferences.Experimental.self, from: data)
return exp
}
return nil
}
}
func saveAsCurrent() throws {
let encoder = JSONEncoder()
let json = try encoder.encode(self)
UserDefaults.standard.setValue(json, forKey: UserPreferences.saveKey)
}
static func loadAsCurrent() throws -> UserPreferences? {
let decoder = JSONDecoder()
if let data = UserDefaults.standard.data(forKey: UserPreferences.saveKey) {
let pref = try decoder.decode(UserPreferences.self, from: data)
return pref
}
return nil
}
// Enums and other
enum DisplayedName: Codable, CaseIterable {
case username, displayName, both
}
enum ProfilePictureShape: Codable, CaseIterable {
case circle, rounded
}
}

View File

@ -35,7 +35,7 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Threaded is a very simple Mastodon client, that is meant to look like the newest social media Threads made by Meta Platforms. It integrates perfectly with your Mastodon account, and matches the Threads vibe, while having Mastodon-only features.\n\nThreaded is a 100% free, made in France using SwiftUI, [open-source](https://github.com/lumaa-dev/ThreadedApp), and doesn't violate [your privacy](https://apps.lumaa.fr/legal/privacy). [Learn more](https://apps.lumaa.fr/app/threaded)"
"value" : "Threaded is a very simple Mastodon client, that is meant to look like the newest social media Threads made by Meta Platforms. It integrates perfectly with your Mastodon account, and matches the Threads vibe, while having Mastodon-only features.\n\nThreaded is a 100% free, made in France using SwiftUI, [open-source](https://github.com/lumaa-dev/ThreadedApp), and doesn't violate [your privacy](https://apps.lumaa.fr/legal/privacy).\n\nThreaded is not related or affiliated to Meta Platforms."
}
}
}
@ -134,6 +134,16 @@
}
}
},
"experimental" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Experimental"
}
}
}
},
"Hello world" : {
},
@ -246,6 +256,99 @@
}
}
}
},
"setting.appearence" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Appearence"
}
}
}
},
"setting.appearence.displayed-name" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Displayed Name"
}
}
}
},
"setting.appearence.displayed-name.both" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Both"
}
}
}
},
"setting.appearence.displayed-name.display-name" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Display Name"
}
}
}
},
"setting.appearence.displayed-name.username" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Username"
}
}
}
},
"setting.appearence.pfp-shape" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Shape of Profile Pictures"
}
}
}
},
"setting.appearence.pfp-shape.circle" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Circle"
}
}
}
},
"setting.appearence.pfp-shape.rounded" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rounded Rectangle"
}
}
}
},
"setting.appearence.reply-symbols" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Show Reply Symbols"
}
}
}
},
"setting.experimental.activate" : {
},
"setting.privacy" : {
"localizations" : {
@ -267,6 +370,26 @@
}
}
},
"settings.cancel" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cancel"
}
}
}
},
"settings.done" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Done"
}
}
}
},
"status.favourites-%lld" : {
"localizations" : {
"en" : {

View File

@ -3,6 +3,8 @@
import SwiftUI
struct ContentView: View {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
@State private var navigator = Navigator()
@State private var sheet: SheetDestination?
@State private var accountManager: AccountManager = AccountManager()
@ -46,6 +48,7 @@ struct ContentView: View {
.withSheets(sheetDestination: $navigator.presentedSheet)
.environment(accountManager)
.environment(navigator)
.environment(appDelegate)
.task {
await recognizeAccount()
}

View File

@ -3,11 +3,280 @@
import SwiftUI
struct PostDetailsView: View {
@Environment(Navigator.self) private var navigator: Navigator
@Environment(AccountManager.self) private var accountManager: AccountManager
var status: Status
@State private var initialLike: Bool = false
@State private var isLiked: Bool = false
@State private var isReposted: Bool = false
@State private var hasQuote: Bool = false
@State private var quoteStatus: Status? = nil
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
VStack {
statusPost(status, isMain: true)
}
}
@ViewBuilder
func statusPost(_ status: AnyStatus, isMain: Bool = false) -> some View {
VStack {
HStack {
profilePicture
.onTapGesture {
navigator.navigate(to: .account(acc: status.account))
}
Text(status.account.username)
.multilineTextAlignment(.leading)
.bold()
.onTapGesture {
navigator.navigate(to: .account(acc: status.account))
}
}
VStack(alignment: .leading) {
// MARK: Status main content
VStack(alignment: .leading, spacing: 10) {
if !status.content.asRawText.isEmpty {
TextEmoji(status.content, emojis: status.emojis, language: status.language)
.multilineTextAlignment(.leading)
.frame(width: 300, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
.font(.callout)
}
if status.card != nil && status.mediaAttachments.isEmpty {
PostCardView(card: status.card!)
}
if !status.mediaAttachments.isEmpty {
ForEach(status.mediaAttachments) { attachment in
PostAttachment(attachment: attachment)
}
}
// if hasQuote {
// if quoteStatus != nil {
// QuotePostView(status: quoteStatus!)
// } else {
// ProgressView()
// .progressViewStyle(.circular)
// }
// }
}
//MARK: Action buttons
HStack(spacing: 13) {
asyncActionButton(isLiked ? "heart.fill" : "heart") {
do {
try await likePost()
HapticManager.playHaptics(haptics: Haptic.tap)
} catch {
HapticManager.playHaptics(haptics: Haptic.error)
print("Error: \(error.localizedDescription)")
}
}
actionButton("bubble.right") {
print("reply")
navigator.presentedSheet = .post()
}
asyncActionButton(isReposted ? "bolt.horizontal.fill" : "bolt.horizontal") {
do {
try await repostPost()
HapticManager.playHaptics(haptics: Haptic.tap)
} catch {
HapticManager.playHaptics(haptics: Haptic.error)
print("Error: \(error.localizedDescription)")
}
}
ShareLink(item: URL(string: status.url ?? "https://joinmastodon.org/")!) {
Image(systemName: "square.and.arrow.up")
.font(.title2)
}
.tint(Color(uiColor: UIColor.label))
}
.padding(.top)
// MARK: Status stats
stats.padding(.top, 5)
}
}
}
func likePost() async throws {
if let client = accountManager.getClient() {
guard client.isAuth else { fatalError("Client is not authenticated") }
let statusId: String = status.reblog != nil ? status.reblog!.id : status.id
let endpoint = !isLiked ? Statuses.favorite(id: statusId) : Statuses.unfavorite(id: statusId)
isLiked = !isLiked
let newStatus: Status = try await client.post(endpoint: endpoint)
if isLiked != newStatus.favourited {
isLiked = newStatus.favourited ?? !isLiked
}
}
}
func repostPost() async throws {
if let client = accountManager.getClient() {
guard client.isAuth else { fatalError("Client is not authenticated") }
let statusId: String = status.reblog != nil ? status.reblog!.id : status.id
let endpoint = !isReposted ? Statuses.reblog(id: statusId) : Statuses.unreblog(id: statusId)
isReposted = !isReposted
let newStatus: Status = try await client.post(endpoint: endpoint)
if isReposted != newStatus.reblogged {
isReposted = newStatus.reblogged ?? !isReposted
}
}
}
var pinnedNotice: some View {
HStack (alignment:.center, spacing: 5) {
Image(systemName: "pin.fill")
Text("status.pinned")
}
.padding(.leading, 20)
.multilineTextAlignment(.leading)
.lineLimit(1)
.font(.caption)
.foregroundStyle(Color(uiColor: UIColor.label).opacity(0.3))
}
var repostNotice: some View {
HStack (alignment:.center, spacing: 5) {
Image(systemName: "bolt.horizontal")
Text("status.reposted-by.\(status.account.username)")
}
.padding(.leading, 20)
.multilineTextAlignment(.leading)
.lineLimit(1)
.font(.caption)
.foregroundStyle(Color(uiColor: UIColor.label).opacity(0.3))
}
var profilePicture: some View {
if status.reblog != nil {
OnlineImage(url: status.reblog!.account.avatar, size: 50, useNuke: true)
.frame(width: 40, height: 40)
.padding(.horizontal)
.clipShape(.circle)
} else {
OnlineImage(url: status.account.avatar, size: 50, useNuke: true)
.frame(width: 40, height: 40)
.padding(.horizontal)
.clipShape(.circle)
}
}
var stats: some View {
//MARK: I acknowledge the existance of a count bug here
if status.reblog == nil {
HStack {
if status.repliesCount > 0 {
Text("status.replies-\(status.repliesCount)")
.monospacedDigit()
.foregroundStyle(.gray)
}
if status.repliesCount > 0 && (status.favouritesCount > 0 || isLiked) {
Text("")
.foregroundStyle(.gray)
}
if status.favouritesCount > 0 || isLiked {
let likeCount: Int = status.favouritesCount - (initialLike ? 1 : 0)
let incrLike: Int = isLiked ? 1 : 0
Text("status.favourites-\(likeCount + incrLike)")
.monospacedDigit()
.foregroundStyle(.gray)
.contentTransition(.numericText(value: Double(likeCount + incrLike)))
.transaction { t in
t.animation = .default
}
}
}
} else {
HStack {
if status.reblog!.repliesCount > 0 {
Text("status.replies-\(status.reblog!.repliesCount)")
.monospacedDigit()
.foregroundStyle(.gray)
}
if status.reblog!.repliesCount > 0 && (status.reblog!.favouritesCount > 0 || isLiked) {
Text("")
.foregroundStyle(.gray)
}
if status.reblog!.favouritesCount > 0 || isLiked {
let likeCount: Int = status.reblog!.favouritesCount - (initialLike ? 1 : 0)
let incrLike: Int = isLiked ? 1 : 0
Text("status.favourites-\(likeCount + incrLike)")
.monospacedDigit()
.foregroundStyle(.gray)
.contentTransition(.numericText(value: Double(likeCount + incrLike)))
.transaction { t in
t.animation = .default
}
}
}
}
}
private func embededStatusURL() -> URL? {
let content = status.content
if let client = accountManager.getClient() {
if !content.statusesURLs.isEmpty, let url = content.statusesURLs.first, client.hasConnection(with: url) {
return url
}
}
return nil
}
func loadEmbeddedStatus() async {
guard let url = embededStatusURL(), let client = accountManager.getClient() else { hasQuote = false; return }
do {
hasQuote = true
if url.absoluteString.contains(client.server), let id = Int(url.lastPathComponent) {
quoteStatus = try await client.get(endpoint: Statuses.status(id: String(id)))
} else {
let results: SearchResults = try await client.get(endpoint: Search.search(query: url.absoluteString, type: "statuses", offset: 0, following: nil), forceVersion: .v2)
quoteStatus = results.statuses.first
}
} catch {
hasQuote = false
quoteStatus = nil
}
}
@ViewBuilder
func actionButton(_ image: String, action: @escaping () -> Void) -> some View {
Button {
action()
} label: {
Image(systemName: image)
.font(.title2)
}
.tint(Color(uiColor: UIColor.label))
}
@ViewBuilder
func asyncActionButton(_ image: String, action: @escaping () async -> Void) -> some View {
Button {
Task {
await action()
}
} label: {
Image(systemName: image)
.font(.title2)
}
.tint(Color(uiColor: UIColor.label))
}
}
#Preview {
PostDetailsView()
}

View File

@ -7,6 +7,7 @@ import PhotosUI
struct PostingView: View {
@Environment(\.dismiss) private var dismiss
@Environment(AccountManager.self) private var accountManager: AccountManager
@Environment(Navigator.self) private var navigator: Navigator
var initialString: String = ""
@State private var postText: NSMutableAttributedString = .init(string: "")
@ -87,9 +88,10 @@ struct PostingView: View {
Task {
if let client = accountManager.getClient() {
postingStatus = true
try await client.post(endpoint: Statuses.postStatus(json: .init(status: postText.string, visibility: visibility)))
let postedStatus: Status = try await client.post(endpoint: Statuses.postStatus(json: .init(status: postText.string, visibility: visibility)))
postingStatus = false
dismiss()
// navigate to post
}
}
} label: {

View File

@ -3,6 +3,8 @@
import SwiftUI
struct AboutView: View {
@ObservedObject private var userPreferences: UserPreferences = .defaultPreferences
var body: some View {
List {
NavigationLink {
@ -11,14 +13,33 @@ struct AboutView: View {
Text("about.app")
.tint(Color.blue)
}
.listRowSeparator(.hidden)
.listRowBackground(Color.appBackground)
.listRowThreaded()
Toggle("setting.experimental.activate", isOn: $userPreferences.showExperimental)
.listRowThreaded()
.onAppear {
do {
let oldPreferences = try UserPreferences.loadAsCurrent() ?? UserPreferences.defaultPreferences
userPreferences.showExperimental = oldPreferences.showExperimental
} catch {
print(error)
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackground)
.listThreaded()
.navigationTitle("about")
.navigationBarTitleDisplayMode(.inline)
.onDisappear {
do {
if !userPreferences.showExperimental {
userPreferences.experimental = .init()
}
try userPreferences.saveAsCurrent()
} catch {
print(error)
}
}
}
var aboutApp: some View {
@ -31,8 +52,7 @@ struct AboutView: View {
}
.padding(.horizontal)
}
.scrollContentBackground(.hidden)
.background(Color.appBackground)
.listThreaded()
.navigationTitle("about.app")
.navigationBarTitleDisplayMode(.large)
}

View File

@ -3,8 +3,103 @@
import SwiftUI
struct AppearenceView: View {
@ObservedObject private var userPreferences: UserPreferences = .defaultPreferences
@Environment(Navigator.self) private var navigator: Navigator
@Environment(\.dismiss) private var dismiss
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
List {
Picker(LocalizedStringKey("setting.appearence.displayed-name"), selection: $userPreferences.displayedName) {
ForEach(UserPreferences.DisplayedName.allCases, id: \.self) { displayCase in
switch (displayCase) {
case .username:
Text("setting.appearence.displayed-name.username")
.tag(UserPreferences.DisplayedName.username)
case .displayName:
Text("setting.appearence.displayed-name.display-name")
.tag(UserPreferences.DisplayedName.displayName)
case .both:
Text("setting.appearence.displayed-name.both")
.tag(UserPreferences.DisplayedName.both)
}
}
}
.pickerStyle(.inline)
.listRowThreaded()
Picker(LocalizedStringKey("setting.appearence.pfp-shape"), selection: $userPreferences.profilePictureShape) {
ForEach(UserPreferences.ProfilePictureShape.allCases, id: \.self) { displayCase in
switch (displayCase) {
case .circle:
Text("setting.appearence.pfp-shape.circle")
.tag(UserPreferences.ProfilePictureShape.circle)
case .rounded:
Text("setting.appearence.pfp-shape.rounded")
.tag(UserPreferences.ProfilePictureShape.rounded)
}
}
}
.pickerStyle(.inline)
.listRowThreaded()
if userPreferences.showExperimental {
Section(header: Text("experimental")) {
Toggle(LocalizedStringKey("setting.appearence.reply-symbols"), isOn: $userPreferences.experimental.replySymbol)
.listRowThreaded()
}
}
}
.listThreaded()
.navigationTitle("setting.appearence")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden()
.onAppear {
do {
let oldPreferences = try UserPreferences.loadAsCurrent() ?? UserPreferences.defaultPreferences
userPreferences.displayedName = oldPreferences.displayedName
userPreferences.profilePictureShape = oldPreferences.profilePictureShape
userPreferences.experimental.replySymbol = oldPreferences.experimental.replySymbol
} catch {
print(error)
dismiss()
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
do {
let oldPreferences = try UserPreferences.loadAsCurrent() ?? UserPreferences.defaultPreferences
userPreferences.displayedName = oldPreferences.displayedName
userPreferences.profilePictureShape = oldPreferences.profilePictureShape
userPreferences.experimental.replySymbol = oldPreferences.experimental.replySymbol
dismiss()
} catch {
print(error)
dismiss()
}
} label: {
Text("settings.cancel")
}
}
ToolbarItem(placement: .primaryAction) {
Button {
do {
try userPreferences.saveAsCurrent()
dismiss()
} catch {
print(error)
}
} label: {
Text("settings.done")
}
}
}
}
}

View File

@ -9,39 +9,42 @@ struct SettingsView: View {
var body: some View {
NavigationStack(path: $navigator.path) {
List {
Button {
navigator.navigate(to: .about)
} label: {
Label("about", systemImage: "info.circle")
}
.listRowSeparator(.hidden)
.listRowBackground(Color.appBackground)
Section {
Button {
navigator.navigate(to: .about)
} label: {
Label("about", systemImage: "info.circle")
}
.listRowThreaded()
Button {
navigator.navigate(to: .privacy)
} label: {
Label("setting.privacy", systemImage: "lock")
}
.listRowSeparator(.hidden)
.listRowBackground(Color.appBackground)
Button {
navigator.navigate(to: .privacy)
} label: {
Label("setting.privacy", systemImage: "lock")
}
.listRowThreaded()
Button {
AppAccount.clear()
sheet = .welcome
} label: {
Text("logout")
.foregroundStyle(.red)
Button {
navigator.navigate(to: .appearence)
} label: {
Label("setting.appearence", systemImage: "rectangle.3.group")
}
.listRowThreaded()
Button {
AppAccount.clear()
sheet = .welcome
} label: {
Text("logout")
.foregroundStyle(.red)
}
.tint(Color.red)
.listRowThreaded()
}
.tint(Color.red)
.listRowSeparator(.hidden)
.listRowBackground(Color.appBackground)
}
.withAppRouter(navigator)
.withCovers(sheetDestination: $sheet)
.scrollContentBackground(.hidden)
.tint(Color.white)
.background(Color.appBackground)
.listStyle(.inset)
.listThreaded()
.navigationTitle("settings")
.navigationBarTitleDisplayMode(.inline)
}