Activity, rework of Navigator and KeychainSwift

This commit is contained in:
Lumaa 2024-02-04 08:43:56 +01:00
parent 6ea7d3a620
commit 0c000dbc9d
23 changed files with 466 additions and 224 deletions

View File

@ -14,6 +14,7 @@ Threaded is a 100% free, made in SwiftUI, [#OpenSource](https://github.com/luma
- [SwiftSoup](https://github.com/scinfu/SwiftSoup)
- [Nuke](https://github.com/kean/Nuke)
- [EmojiText](https://github.com/divadretlaw/EmojiText)
- [KeychainSwift](https://github.com/evgenyneu/keychain-swift)
## To-do list

View File

@ -15,6 +15,7 @@
B93B67782B42E8F0000892E9 /* TextEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93B67772B42E8F0000892E9 /* TextEmoji.swift */; };
B93B677A2B42EC51000892E9 /* MetaPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93B67792B42EC51000892E9 /* MetaPicker.swift */; };
B93B677C2B433A6E000892E9 /* PostingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93B677B2B433A6E000892E9 /* PostingView.swift */; };
B97491E32B6E96700098BC48 /* SymbolWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97491E22B6E96700098BC48 /* SymbolWidth.swift */; };
B97BCE242B3DD8400044756D /* HapticManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97BCE232B3DD8400044756D /* HapticManager.swift */; };
B97BCE262B3DE5A10044756D /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97BCE252B3DE5A10044756D /* AccountView.swift */; };
B97BCE282B3ED2A80044756D /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = B97BCE272B3ED2A80044756D /* .gitignore */; };
@ -39,6 +40,7 @@
B9BED5162B5D5E6500C9B715 /* PostInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9BED5152B5D5E6500C9B715 /* PostInteractor.swift */; };
B9BED5182B5D649C00C9B715 /* PostMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9BED5172B5D649C00C9B715 /* PostMenu.swift */; };
B9BED51A2B5D662D00C9B715 /* ShareSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9BED5192B5D662D00C9B715 /* ShareSheetController.swift */; };
B9BF54072B6B6823004B24E7 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = B9BF54062B6B6823004B24E7 /* KeychainSwift */; };
B9CC45B82B40A2D6001E4FA5 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9CC45B72B40A2D6001E4FA5 /* AboutView.swift */; };
B9CFC43B2B4F08C9004CFCB7 /* LaunchStoryboard.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B9CFC43A2B4F08C9004CFCB7 /* LaunchStoryboard.storyboard */; };
B9D9C6C12B6A56E000C26A41 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9D9C6C02B6A56E000C26A41 /* Notification.swift */; };
@ -107,6 +109,7 @@
B93B67772B42E8F0000892E9 /* TextEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextEmoji.swift; sourceTree = "<group>"; };
B93B67792B42EC51000892E9 /* MetaPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaPicker.swift; sourceTree = "<group>"; };
B93B677B2B433A6E000892E9 /* PostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingView.swift; sourceTree = "<group>"; };
B97491E22B6E96700098BC48 /* SymbolWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymbolWidth.swift; sourceTree = "<group>"; };
B97BCE232B3DD8400044756D /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = "<group>"; };
B97BCE252B3DE5A10044756D /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = "<group>"; };
B97BCE272B3ED2A80044756D /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = "<group>"; };
@ -182,6 +185,7 @@
B93B67762B42E8AB000892E9 /* EmojiText in Frameworks */,
B9FB94842B2E20AF00D81C07 /* SwiftSoup in Frameworks */,
B93B676D2B42C94F000892E9 /* Nuke in Frameworks */,
B9BF54072B6B6823004B24E7 /* KeychainSwift in Frameworks */,
B93B67732B42C94F000892E9 /* NukeVideo in Frameworks */,
B93B676F2B42C94F000892E9 /* NukeExtensions in Frameworks */,
B93B67712B42C94F000892E9 /* NukeUI in Frameworks */,
@ -332,6 +336,7 @@
B9BED5192B5D662D00C9B715 /* ShareSheetController.swift */,
B9D9C6C42B6A587700C26A41 /* NotificationRow.swift */,
B9D9C6C62B6A590F00C26A41 /* ProfilePicture.swift */,
B97491E22B6E96700098BC48 /* SymbolWidth.swift */,
);
path = Components;
sourceTree = "<group>";
@ -402,6 +407,7 @@
B93B67702B42C94F000892E9 /* NukeUI */,
B93B67722B42C94F000892E9 /* NukeVideo */,
B93B67752B42E8AB000892E9 /* EmojiText */,
B9BF54062B6B6823004B24E7 /* KeychainSwift */,
);
productName = Threaded;
productReference = B9FB94572B2DEECE00D81C07 /* Threaded.app */;
@ -456,6 +462,7 @@
B9FB94822B2E20AF00D81C07 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
B93B676B2B42C94F000892E9 /* XCRemoteSwiftPackageReference "Nuke" */,
B93B67742B42E8AB000892E9 /* XCRemoteSwiftPackageReference "EmojiText" */,
B9BF54052B6B6823004B24E7 /* XCRemoteSwiftPackageReference "keychain-swift" */,
);
productRefGroup = B9FB94582B2DEECE00D81C07 /* Products */;
projectDirPath = "";
@ -525,6 +532,7 @@
B9FB94722B2DF49700D81C07 /* ConnectView.swift in Sources */,
B9FB945B2B2DEECE00D81C07 /* ThreadedApp.swift in Sources */,
B9FB94862B2E211200D81C07 /* Account+Elms.swift in Sources */,
B97491E32B6E96700098BC48 /* SymbolWidth.swift in Sources */,
B9FB94BC2B2F035500D81C07 /* Tag.swift in Sources */,
B9BED51A2B5D662D00C9B715 /* ShareSheetController.swift in Sources */,
B9BED5162B5D5E6500C9B715 /* PostInteractor.swift in Sources */,
@ -887,6 +895,14 @@
minimumVersion = 3.3.0;
};
};
B9BF54052B6B6823004B24E7 /* XCRemoteSwiftPackageReference "keychain-swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/evgenyneu/keychain-swift?tab=readme-ov-file#usage";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 21.0.0;
};
};
B9FB94822B2E20AF00D81C07 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/scinfu/SwiftSoup";
@ -923,6 +939,11 @@
package = B93B67742B42E8AB000892E9 /* XCRemoteSwiftPackageReference "EmojiText" */;
productName = EmojiText;
};
B9BF54062B6B6823004B24E7 /* KeychainSwift */ = {
isa = XCSwiftPackageProductDependency;
package = B9BF54052B6B6823004B24E7 /* XCRemoteSwiftPackageReference "keychain-swift" */;
productName = KeychainSwift;
};
B9FB94832B2E20AF00D81C07 /* SwiftSoup */ = {
isa = XCSwiftPackageProductDependency;
package = B9FB94822B2E20AF00D81C07 /* XCRemoteSwiftPackageReference "SwiftSoup" */;

View File

@ -9,6 +9,15 @@
"version" : "3.3.0"
}
},
{
"identity" : "keychain-swift?tab=readme-ov-file#usage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/evgenyneu/keychain-swift?tab=readme-ov-file#usage",
"state" : {
"revision" : "265806607b45687a3d646e4c9837c31c90f202e8",
"version" : "21.0.0"
}
},
{
"identity" : "nuke",
"kind" : "remoteSourceControl",

View File

@ -3,38 +3,68 @@
import SwiftUI
struct NotificationRow: View {
@Environment(Navigator.self) private var navigator: Navigator
var notif: Notification = .placeholder()
var body: some View {
VStack {
HStack(spacing: 5) {
ProfilePicture(url: notif.account.avatar)
ProfilePicture(url: notif.account.avatar, size: 60)
.padding(.trailing)
.overlay(alignment: .bottomTrailing) {
notifIcon()
.offset(x: -5, y: 5)
}
.padding()
Text(localizedString())
.padding(.horizontal, 10)
.onTapGesture {
navigator.navigate(to: .account(acc: notif.account))
}
VStack(alignment: .leading) {
Text(localizedString())
.multilineTextAlignment(.leading)
.font(.subheadline)
.lineLimit(2)
if notif.status != nil {
TextEmoji(notif.status!.content, emojis: notif.status!.emojis)
.multilineTextAlignment(.leading)
.font(.caption)
.foregroundStyle(Color.gray)
.lineLimit(3, reservesSpace: true)
} else {
TextEmoji(notif.account.note, emojis: notif.account.emojis)
.multilineTextAlignment(.leading)
.font(.caption)
.foregroundStyle(Color.gray)
.lineLimit(3, reservesSpace: true)
}
}
.contentShape(Rectangle())
.onTapGesture {
navigator.navigate(to: notif.status == nil ? .account(acc: notif.account) : .post(status: notif.status!))
}
}
.padding(.horizontal)
}
}
private func localizedString() -> String {
private func localizedString() -> LocalizedStringKey {
let nameStr = "@\(notif.account.username)"
switch (notif.supportedType) {
case .favourite:
return String(localized: "activity.favorite.%@").replacingOccurrences(of: "%@", with: "@\(notif.account.username)")
return "activity.favorite.\(nameStr)"
case .follow:
return String(localized: "activity.followed.%@").replacingOccurrences(of: "%@", with: "@\(notif.account.username)")
return "activity.followed.\(nameStr)"
case .mention:
return String(localized: "activity.mentionned.%@").replacingOccurrences(of: "%@", with: "@\(notif.account.username)")
return "activity.mentionned.\(nameStr)"
case .reblog:
return String(localized: "activity.reblogged.%@").replacingOccurrences(of: "%@", with: "@\(notif.account.username)")
return "activity.reblogged.\(nameStr)"
case .status:
return String(localized: "activity.status.%@").replacingOccurrences(of: "%@", with: "@\(notif.account.username)")
return "activity.status.\(nameStr)"
default:
return String(localized: "activity.unknown")
return "activity.unknown" // follow requests & polls
}
}
@ -57,29 +87,32 @@ struct NotificationRow: View {
@ViewBuilder
private func notifIcon() -> some View {
let font: Font = .system(size: 12, weight: .regular, design: .monospaced)
ZStack {
switch (notif.supportedType) {
case .favourite:
Image(systemName: "heart.fill")
.font(.caption)
.font(font)
case .follow:
Image(systemName: "person.fill.badge.plus")
.font(.caption)
.font(font)
case .mention:
Image(systemName: "tag.fill")
.font(.caption)
.font(font)
case .reblog:
Image(systemName: "bolt.horizontal.fill")
.font(.caption)
.font(font)
case .status:
Image(systemName: "text.badge.plus")
.font(.caption)
.font(font)
default:
Image(systemName: "questionmark")
.font(.caption)
.font(font)
}
}
.padding(5)
.frame(minWidth: 30)
.padding(7)
.background(notifColor())
.clipShape(.circle)
.overlay {

View File

@ -4,8 +4,8 @@ import SwiftUI
struct CompactPostView: View {
@Environment(AccountManager.self) private var accountManager: AccountManager
@Environment(Navigator.self) private var navigator: Navigator
@State var status: Status
@ObservedObject var navigator: Navigator
var pinned: Bool = false
var detailed: Bool = false
@ -23,7 +23,7 @@ struct CompactPostView: View {
@State private var quoteStatus: Status? = nil
var body: some View {
VStack {
VStack(alignment: .leading) {
notices
statusPost(status.reblogAsAsStatus ?? status)
@ -35,6 +35,7 @@ struct CompactPostView: View {
.padding(.bottom, 3)
}
}
.environment(navigator)
.onAppear {
do {
preferences = try UserPreferences.loadAsCurrent() ?? UserPreferences.defaultPreferences
@ -205,17 +206,8 @@ struct CompactPostView: View {
}
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)
}
ProfilePicture(url: status.reblog?.account.avatar ?? status.account.avatar)
.padding(.horizontal)
}
var stats: some View {

View File

@ -4,7 +4,7 @@ import SwiftUI
struct PostInteractor: View {
@Environment(AccountManager.self) private var accountManager
@Environment(Navigator.self) private var navigator
@Environment(UniversalNavigator.self) private var navigator
var status: Status
@ -56,12 +56,16 @@ struct PostInteractor: View {
}
.padding(.top)
.onAppear {
isLiked = status.reblog != nil ? status.reblog!.favourited ?? false : status.favourited ?? false
isReposted = status.reblog != nil ? status.reblog!.reblogged ?? false : status.reblogged ?? false
isBookmarked = status.reblog != nil ? status.reblog!.bookmarked ?? false : status.bookmarked ?? false
syncInteractors(status: status)
}
}
func syncInteractors(status: Status) {
isLiked = status.reblog != nil ? status.reblog!.favourited ?? false : status.favourited ?? false
isReposted = status.reblog != nil ? status.reblog!.reblogged ?? false : status.reblogged ?? false
isBookmarked = status.reblog != nil ? status.reblog!.bookmarked ?? false : status.bookmarked ?? false
}
func likePost() async throws {
if let client = accountManager.getClient() {
guard client.isAuth else { fatalError("Client is not authenticated") }
@ -70,9 +74,7 @@ struct PostInteractor: View {
isLiked = !isLiked
let newStatus: Status = try await client.post(endpoint: endpoint)
if isLiked != newStatus.favourited {
isLiked = newStatus.favourited ?? !isLiked
}
syncInteractors(status: status)
}
}
@ -84,9 +86,7 @@ struct PostInteractor: View {
isReposted = !isReposted
let newStatus: Status = try await client.post(endpoint: endpoint)
if isReposted != newStatus.reblogged {
isReposted = newStatus.reblogged ?? !isReposted
}
syncInteractors(status: status)
}
}
@ -98,9 +98,7 @@ struct PostInteractor: View {
isBookmarked = !isBookmarked
let newStatus: Status = try await client.post(endpoint: endpoint)
if isBookmarked != newStatus.bookmarked {
isBookmarked = newStatus.bookmarked ?? !isBookmarked
}
syncInteractors(status: status)
}
}

View File

@ -66,13 +66,15 @@ struct PostMenu: View {
Image(systemName: "ellipsis")
.foregroundStyle(Color.white.opacity(0.3))
.font(.body)
.contentShape(Rectangle())
.padding(7.5)
}
}
@MainActor
private func createImage() {
let view = HStack {
CompactPostView(status: status, navigator: Navigator(), imaging: true)
CompactPostView(status: status, imaging: true)
.padding(15)
.background(Color.appBackground)
}

View File

@ -7,7 +7,8 @@ struct QuotePostView: View {
var status: Status
var body: some View {
CompactPostView(status: status, navigator: navigator, quoted: true)
CompactPostView(status: status, quoted: true)
.environment(navigator)
.frame(maxWidth: 250, maxHeight: 200)
.padding(15)
.padding([.horizontal], 20)

View File

@ -3,10 +3,17 @@
import SwiftUI
struct ProfilePicture: View {
@Environment(UserPreferences.self) private var pref
@EnvironmentObject private var pref: UserPreferences
var url: URL
var size: CGFloat = 50.0
init(url: URL, size: CGFloat = 50.0) {
self.url = url
self.size = size
}
var cornerRadius: CGFloat {
return pref.profilePictureShape == .circle ? (50 / 2) : 15.0
return pref.profilePictureShape == .circle ? (50 / 2) : 10.0
}
init(url: URL) {
@ -18,8 +25,8 @@ struct ProfilePicture: View {
}
var body: some View {
OnlineImage(url: url, size: 50, useNuke: true)
.frame(width: 40, height: 40)
OnlineImage(url: url, size: size, useNuke: true)
.frame(width: size - 10, height: size - 10)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}
}

View File

@ -0,0 +1,31 @@
//Made by Lumaa
import Foundation
import SwiftUI
struct SymbolWidthPreferenceKey: PreferenceKey {
static var defaultValue: Double = 0
static func reduce(value: inout Double, nextValue: () -> Double) {
value = max(value, nextValue())
}
}
struct SymbolWidthModifier: ViewModifier {
@Binding var width: Double
func body(content: Content) -> some View {
content
.background(GeometryReader { geo in
Color
.clear
.preference(key: SymbolWidthPreferenceKey.self, value: geo.size.width)
})
}
}
extension Image {
func sync(with width: Binding<Double>) -> some View {
modifier(SymbolWidthModifier(width: width))
}
}

View File

@ -3,14 +3,15 @@
import SwiftUI
struct TabsView: View {
@State var navigator: Navigator
@Binding var selectedTab: TabDestination
var postButton: () -> Void
var body: some View {
HStack(alignment: .center) {
Button {
navigator.selectedTab = .timeline
selectedTab = .timeline
} label: {
if navigator.selectedTab == .timeline {
if selectedTab == .timeline {
Tabs.timeline.imageFill
} else {
Tabs.timeline.image
@ -21,9 +22,9 @@ struct TabsView: View {
Spacer()
Button {
navigator.selectedTab = .search
selectedTab = .search
} label: {
if navigator.selectedTab == .search {
if selectedTab == .search {
Tabs.search.imageFill
} else {
Tabs.search.image
@ -34,7 +35,7 @@ struct TabsView: View {
Spacer()
Button {
navigator.presentedSheet = .post()
postButton()
} label: {
Tabs.post.image
}
@ -43,9 +44,9 @@ struct TabsView: View {
Spacer()
Button {
navigator.selectedTab = .activity
selectedTab = .activity
} label: {
if navigator.selectedTab == .activity {
if selectedTab == .activity {
Tabs.activity.imageFill
} else {
Tabs.activity.image
@ -56,9 +57,9 @@ struct TabsView: View {
Spacer()
Button {
navigator.selectedTab = .profile
selectedTab = .profile
} label: {
if navigator.selectedTab == .profile {
if selectedTab == .profile {
Tabs.profile.imageFill
} else {
Tabs.profile.image
@ -130,8 +131,3 @@ extension Image {
.opacity(neutral ? 0.3 : 1)
}
}
#Preview {
TabsView(navigator: Navigator())
.previewLayout(.sizeThatFits)
}

View File

@ -1,8 +1,7 @@
//Made by Lumaa
import Foundation
//TODO: Change this to SwiftData
import KeychainSwift
@Observable
public class AccountManager {
@ -52,7 +51,13 @@ public struct AppAccount: Codable, Identifiable, Hashable {
public let server: String
public var accountName: String?
public let oauthToken: OauthToken?
private static let saveKey: String = "threaded-appaccount.current"
private static var keychain: KeychainSwift {
let kc = KeychainSwift()
// synchronise later
return kc
}
public var key: String {
if let oauthToken {
@ -66,32 +71,40 @@ public struct AppAccount: Codable, Identifiable, Hashable {
key
}
public init(server: String,
accountName: String?,
oauthToken: OauthToken? = nil)
{
public init(server: String, accountName: String?, oauthToken: OauthToken? = nil) {
self.server = server
self.accountName = accountName
self.oauthToken = oauthToken
}
func saveAsCurrent() throws {
public static func clear() {
Self.keychain.delete(Self.saveKey)
}
public func clear() {
Self.clear()
}
public func saveAsCurrent(_ appAccount: AppAccount? = nil) {
let encoder = JSONEncoder()
let json = try encoder.encode(self)
UserDefaults.standard.setValue(json, forKey: AppAccount.saveKey)
}
static func loadAsCurrent() throws -> AppAccount? {
let decoder = JSONDecoder()
if let data = UserDefaults.standard.data(forKey: AppAccount.saveKey) {
let account = try decoder.decode(AppAccount.self, from: data)
return account
if let data = try? encoder.encode(appAccount ?? self) {
Self.keychain.set(data, forKey: Self.saveKey, withAccess: .accessibleWhenUnlocked)
} else {
fatalError("Couldn't encode AppAccount correctly to save")
}
return nil
}
static func clear() {
UserDefaults.standard.removeObject(forKey: AppAccount.saveKey)
public static func loadAsCurrent(_ data: Data? = nil) -> Self? {
let decoder = JSONDecoder()
if let newData = data ?? keychain.getData(Self.saveKey) {
if let decoded = try? decoder.decode(Self.self, from: newData) {
return decoded
} else {
return nil
}
} else {
return nil
}
}
}

View File

@ -3,7 +3,7 @@
import Foundation
public enum AppInfo {
public static let scopes = "read write follow"
public static let scopes = "read write follow push"
public static let scheme = "threaded://"
public static let clientName = "ThreadedApp"
public static let defaultServer = "mastodon.social"

View File

@ -92,10 +92,7 @@ public final class Client: Equatable, Identifiable, Hashable {
}
}
private func makeURL(scheme: String = "https",
endpoint: Endpoint,
forceVersion: Version? = nil,
forceServer: String? = nil) throws -> URL
private func makeURL(scheme: String = "https", endpoint: Endpoint, forceVersion: Version? = nil, forceServer: String? = nil) throws -> URL
{
var components = URLComponents()
components.scheme = scheme
@ -220,9 +217,7 @@ public final class Client: Equatable, Identifiable, Hashable {
else {
throw OauthError.invalidRedirectURL
}
let token: OauthToken = try await post(endpoint: Oauth.token(code: code,
clientId: app.clientId,
clientSecret: app.clientSecret))
let token: OauthToken = try await post(endpoint: Oauth.token(code: code, clientId: app.clientId, clientSecret: app.clientSecret))
critical.withLock { $0.oauthToken = token }
return token
}
@ -236,12 +231,7 @@ public final class Client: Equatable, Identifiable, Hashable {
return urlSession.webSocketTask(with: url, protocols: subprotocols)
}
public func mediaUpload<Entity: Decodable>(endpoint: Endpoint,
version: Version,
method: String,
mimeType: String,
filename: String,
data: Data) async throws -> Entity
public func mediaUpload<Entity: Decodable>(endpoint: Endpoint, version: Version, method: String, mimeType: String, filename: String, data: Data) async throws -> Entity
{
let url = try makeURL(endpoint: endpoint, forceVersion: version)
var request = makeURLRequest(url: url, endpoint: endpoint, httpMethod: method)

View File

@ -7,18 +7,37 @@ import SwiftUI
public class Navigator: ObservableObject {
public var path: [RouterDestination] = []
public var presentedSheet: SheetDestination?
public var presentedCover: SheetDestination?
public var selectedTab: TabDestination = .timeline
public var showTabbar: Bool = true
public func navigate(to: RouterDestination) {
path.append(to)
print("appended view")
if path.contains(where: { $0 == .settings }) {
toggleTabbar(false)
} else {
toggleTabbar(true)
}
}
public func removeSettingsOfPath() {
self.path = self.path.filter({ !RouterDestination.allSettings.contains($0) })
}
/// Defines the visibility of the main tab bar in from `ContentView`
/// - Parameter bool: `true` shows the tab bar and `false` hides the tab bar
public func toggleTabbar(_ bool: Bool? = nil) {
print("\((bool ?? !self.showTabbar) ? "shown" : "hide") the tab bar")
withAnimation(.easeInOut(duration: 0.4)) {
self.showTabbar = bool ?? !self.showTabbar
}
}
}
public class UniversalNavigator: Navigator {}
public enum TabDestination: Identifiable {
case timeline
case search
@ -120,16 +139,24 @@ extension View {
func withSheets(sheetDestination: Binding<SheetDestination?>) -> some View {
sheet(item: sheetDestination) { destination in
viewRepresentation(destination: destination, isCover: false)
viewSheet(destination: destination)
}
}
func withCovers(sheetDestination: Binding<SheetDestination?>) -> some View {
fullScreenCover(item: sheetDestination) { destination in
viewRepresentation(destination: destination, isCover: true)
viewCover(destination: destination)
}
}
@available(*, deprecated, renamed: "withSheets", message: "These two cannot support themselves")
func withOver(sheetDestination: Binding<SheetDestination?>) -> some View {
self
.withCovers(sheetDestination: sheetDestination)
.withSheets(sheetDestination: sheetDestination)
}
@available(*, deprecated, message: "Causes bugs with sheets to display as covers")
private func viewRepresentation(destination: SheetDestination, isCover: Bool) -> some View {
Group {
if destination.isCover {
@ -162,18 +189,66 @@ extension View {
}
}
}
private func viewCover(destination: SheetDestination) -> some View {
Group {
switch destination {
case .welcome:
ConnectView()
case .shop:
ShopView()
default:
EmptySheetView(destId: destination.id)
}
}
}
private func viewSheet(destination: SheetDestination) -> some View {
Group {
switch destination {
case .post(let content, let replyId, let editId):
NavigationStack {
PostingView(initialString: content, replyId: replyId, editId: editId)
.tint(Color(uiColor: UIColor.label))
}
case let .mastodonLogin(logged):
AddInstanceView(logged: logged)
.tint(Color.accentColor)
case let .safari(url):
SfSafariView(url: url)
.ignoresSafeArea()
case let .shareImage(image, status):
ShareSheet(image: image, status: status)
default:
EmptySheetView(destId: destination.id)
}
}
}
}
/// This view is visible when the `viewRepresentation(destination: SheetDestination)` doesn't support the given `SheetDestination`
private struct EmptySheetView: View {
var destId: String = "???"
@Environment(\.dismiss) private var dismiss
var destId: String = ""
let str: String = .init(localized: "about.version-\(AppInfo.appVersion)")
var body: some View {
ZStack {
ContentUnavailableView(String("Missing view for \"\(destId.isEmpty ? "[EMPTY_DEST_ID]" : destId)\""), systemImage: "exclamationmark.triangle.fill", description: Text(String("Please notify Lumaa as soon as possible!\n\n\(str)")))
Rectangle()
.fill(Color.red.gradient)
.ignoresSafeArea()
.background(Color.red.gradient)
.foregroundStyle(.white)
VStack {
ContentUnavailableView(String("Missing view for \"\(destId.isEmpty ? "[EMPTY_DEST_ID]" : destId)\""), systemImage: "exclamationmark.triangle.fill", description: Text(String("Please notify Lumaa as soon as possible!\n\n\(str)")))
.foregroundStyle(.white)
Button {
dismiss()
} label: {
Text(String("Dismiss"))
}
.buttonStyle(LargeButton(filled: true))
}
}
}
}

View File

@ -60,7 +60,7 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Threaded uses third-party open-source libraries and code:\n- [IceCubesApp](https://github.com/dimillian/IceCubesApp)\n- [SwiftSoup](https://github.com/scinfu/SwiftSoup)\n- [Nuke](https://github.com/kean/Nuke)\n- [EmojiText](https://github.com/divadretlaw/EmojiText)"
"value" : "Threaded uses third-party open-source libraries and code:\n- [IceCubesApp](https://github.com/dimillian/IceCubesApp)\n- [SwiftSoup](https://github.com/scinfu/SwiftSoup)\n- [Nuke](https://github.com/kean/Nuke)\n- [EmojiText](https://github.com/divadretlaw/EmojiText)\n- [KeychainSwift](https://github.com/evgenyneu/keychain-swift)"
}
}
}
@ -72,6 +72,12 @@
"state" : "translated",
"value" : "ThreadedApp v%@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "ThreadedApp v%@"
}
}
}
},
@ -194,6 +200,24 @@
}
}
}
},
"fr" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld follower"
}
},
"other" : {
"stringUnit" : {
"state" : "new",
"value" : "%lld followers"
}
}
}
}
}
}
},
@ -213,6 +237,22 @@
}
}
},
"account.no-statuses" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No posts"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aucunes publications"
}
}
}
},
"account.unfollow" : {
"localizations" : {
"en" : {
@ -229,12 +269,34 @@
}
}
},
"activity" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Activity"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Activité"
}
}
}
},
"activity.favorite.%@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ liked your post"
"value" : "**%@** liked your post"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "**%@** a aimé votre publication"
}
}
}
@ -244,13 +306,13 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ followed you"
"value" : "**%@** followed you"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ vous a suivi(e)"
"value" : "**%@** vous a suivi(e)"
}
}
}
@ -260,13 +322,13 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ mentionned you "
"value" : "**%@** mentionned you "
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ vous a mentionné(e)"
"value" : "**%@** vous a mentionné(e)"
}
}
}
@ -292,13 +354,13 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ reposted your post"
"value" : "**%@** reposted your post"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ a republié votre publication"
"value" : "**%@** a republié votre publication"
}
}
}
@ -308,13 +370,13 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ posted"
"value" : "**%@** posted"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ a publié une publication"
"value" : "**%@** a publié une publication"
}
}
}
@ -324,7 +386,7 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unknown activity"
"value" : "Unknown Activity"
}
},
"fr" : {
@ -346,7 +408,7 @@
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Erreur Image"
"value" : "Erreur d'Image"
}
}
}
@ -1290,4 +1352,4 @@
}
},
"version" : "1.0"
}
}

View File

@ -4,6 +4,7 @@ import SwiftUI
struct AccountView: View {
@Environment(AccountManager.self) private var accountManager: AccountManager
@Environment(UniversalNavigator.self) private var uniNav: UniversalNavigator
@Namespace var accountAnims
@Namespace var animPicture
@ -19,32 +20,25 @@ struct AccountView: View {
@State private var accountFollows: Bool = false
@State private var loadingStatuses: Bool = false
@State private var statuses: [Status]?
@State private var statusesPinned: [Status]?
@State private var statuses: [Status]? = []
@State private var statusesPinned: [Status]? = []
@State private var lastSeen: Int?
private let animPicCurve = Animation.smooth(duration: 0.25, extraBounce: 0.0)
var body: some View {
if isCurrent {
if accountManager.getClient() != nil {
NavigationStack(path: $navigator.path) {
accountView
.withSheets(sheetDestination: $navigator.presentedSheet)
.onAppear {
account = accountManager.forceAccount()
}
}
} else {
ZStack {
Color.appBackground
ProgressView()
.progressViewStyle(.circular)
}
NavigationStack(path: $navigator.path) {
accountView
.environment(navigator)
.withAppRouter(navigator)
.onAppear {
account = accountManager.forceAccount()
}
}
} else {
accountView
.environment(navigator)
}
}
@ -88,7 +82,7 @@ struct AccountView: View {
}
}
.task {
await updateRelationship()
await reloadUser()
initialFollowing = isFollowing
}
.refreshable {
@ -144,10 +138,10 @@ struct AccountView: View {
Button {
if let server = account.acct.split(separator: "@").last {
navigator.presentedSheet = .post(content: "@\(account.username)@\(server)")
uniNav.presentedSheet = .post(content: "@\(account.username)@\(server)")
} else {
let client = accountManager.getClient()
navigator.presentedSheet = .post(content: "@\(account.username)@\(client?.server ?? "???")")
uniNav.presentedSheet = .post(content: "@\(account.username)@\(client?.server ?? "???")")
}
} label: {
HStack {
@ -175,24 +169,26 @@ struct AccountView: View {
.safeAreaPadding(.vertical)
.padding(.horizontal)
}
.withAppRouter(navigator)
.environment(navigator)
}
var statusesList: some View {
LazyVStack {
if statuses != nil {
if loadingStatuses == false || statuses == nil {
if !(statusesPinned?.isEmpty ?? true) {
ForEach(statusesPinned!, id: \.id) { status in
CompactPostView(status: status, navigator: navigator, pinned: true)
CompactPostView(status: status, pinned: true)
}
}
if !statuses!.isEmpty {
if !(statuses?.isEmpty ?? true) {
ForEach(statuses!, id: \.id) { status in
CompactPostView(status: status, navigator: navigator)
CompactPostView(status: status)
.onDisappear() {
lastSeen = statuses!.firstIndex(where: { $0.id == status.id })
}
}
} else {
ContentUnavailableView("account.no-statuses", systemImage: "pencil.slash")
}
} else {
ProgressView()

View File

@ -146,7 +146,7 @@ struct AddInstanceView: View {
let client = Client(server: client.server, oauthToken: oauthToken)
let account: Account = try await client.get(endpoint: Accounts.verifyCredentials)
let appAcc = AppAccount(server: client.server, accountName: "\(account.acct)@\(client.server)", oauthToken: oauthToken)
try appAcc.saveAsCurrent()
appAcc.saveAsCurrent()
signInClient = client
logged = true

View File

@ -2,67 +2,63 @@
import SwiftUI
// TODO: Make some sort of "Universal Navigation"?
/// Details: Fix bugs about `navigator.path` when tapping on any element that adds to it.
/// Possibility 1: Put a `NavigationStack` in the parent view of the tabs' view with no title
/// Possibility 2: Make another `Navigator` but universally
///
struct ContentView: View {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
@State private var preferences: UserPreferences = .defaultPreferences
@State private var navigator = Navigator()
@State private var sheet: SheetDestination?
@StateObject private var navigator = UniversalNavigator() // "Universal Path" (POSS 1)
@State private var accountManager: AccountManager = AccountManager()
var body: some View {
TabView(selection: $navigator.selectedTab, content: {
ZStack {
if accountManager.getClient() != nil {
ZStack {
TabView(selection: $navigator.selectedTab, content: {
if accountManager.getAccount() != nil {
TimelineView(navigator: navigator, timelineModel: FetchTimeline(client: accountManager.forceClient()))
.background(Color.appBackground)
.safeAreaPadding()
} else {
ZStack {
Color.appBackground
.ignoresSafeArea()
}
}
}
.background(Color.appBackground)
.tag(TabDestination.timeline)
Text(String("Search"))
.background(Color.appBackground)
.tag(TabDestination.search)
//TODO: Messaging UI in Activity tab
NotificationsView()
.background(Color.appBackground)
.tag(TabDestination.activity)
ZStack {
if accountManager.getAccount() != nil {
AccountView(isCurrent: true, account: accountManager.forceAccount())
.environment(navigator)
.tag(TabDestination.timeline)
Text(String("Search"))
.background(Color.appBackground)
.tag(TabDestination.search)
//TODO: Messaging UI in Activity tab
NotificationsView()
.background(Color.appBackground)
.tag(TabDestination.activity)
AccountView(isCurrent: true, account: accountManager.forceAccount())
.background(Color.appBackground)
.tag(TabDestination.profile)
} else {
ZStack {
Color.appBackground
.ignoresSafeArea()
}
}
}
.background(Color.appBackground)
.tag(TabDestination.profile)
})
.overlay(alignment: .bottom) {
TabsView(navigator: navigator)
.safeAreaPadding(.vertical)
.zIndex(10)
})
}
.overlay(alignment: .bottom) {
if navigator.showTabbar {
TabsView(selectedTab: $navigator.selectedTab) {
navigator.presentedSheet = .post(content: "", replyId: nil, editId: nil)
}
.safeAreaPadding(.vertical)
.offset(y: navigator.showTabbar ? 0 : -20)
.zIndex(10)
}
}
.withCovers(sheetDestination: $sheet)
.withSheets(sheetDestination: $navigator.presentedSheet)
.environment(accountManager)
.withCovers(sheetDestination: $navigator.presentedCover)
.environment(navigator)
.environment(accountManager)
.environment(appDelegate)
.environment(preferences)
.environmentObject(preferences)
.onAppear {
do {
preferences = try UserPreferences.loadAsCurrent() ?? .defaultPreferences
@ -76,9 +72,6 @@ struct ContentView: View {
}
}
}
.task {
await recognizeAccount()
}
.environment(\.openURL, OpenURLAction { url in
// Open internal URL.
guard preferences.browserType == .inApp else { return .systemAction }
@ -92,20 +85,19 @@ struct ContentView: View {
}
func recognizeAccount() async {
let acc = try? AppAccount.loadAsCurrent()
if acc == nil {
sheet = .welcome
let appAccount: AppAccount? = AppAccount.loadAsCurrent()
if appAccount == nil {
navigator.presentedSheet = .welcome
} else {
Task {
accountManager.setClient(.init(server: acc!.server, oauthToken: acc!.oauthToken))
// check if token is still working
let fetched: Account? = await accountManager.fetchAccount()
if fetched == nil {
accountManager.clear()
AppAccount.clear()
sheet = .welcome
}
//TODO: Fix this? (Fatal error: calling into SwiftUI on a non-main thread is not supported)
accountManager.setClient(.init(server: appAccount!.server, oauthToken: appAccount!.oauthToken))
// Check if token is still working
let fetched: Account? = await accountManager.fetchAccount()
if fetched == nil {
accountManager.clear()
appAccount!.clear()
navigator.presentedSheet = .welcome
}
}
}

View File

@ -16,18 +16,21 @@ struct NotificationsView: View {
NavigationStack(path: $navigator.path) {
if !notifications.isEmpty {
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(alignment: .leading) {
LazyVStack(alignment: .leading, spacing: 15) {
ForEach(notifications) { notif in
NotificationRow(notif: notif)
.environment(navigator)
.onDisappear() {
guard !notifications.isEmpty else { return }
lastId = notifications.firstIndex(where: { $0.id == notif.id })
}
}
}
.refreshable {
notifications = []
await fetchNotifications(lastId: nil)
if loadingNotifs {
ProgressView()
.progressViewStyle(.circular)
.padding(.vertical)
}
}
.onChange(of: lastId ?? 0) { _, new in
guard !loadingNotifs else { return }
@ -38,6 +41,12 @@ struct NotificationsView: View {
}
}
}
.withAppRouter(navigator)
.background(Color.appBackground)
.refreshable {
await fetchNotifications(lastId: nil)
}
.navigationTitle(String(localized: "activity"))
} else if loadingNotifs == false && notifications.isEmpty {
ZStack {
Color.appBackground
@ -70,7 +79,6 @@ struct NotificationsView: View {
}
do {
let allCases = Notification.NotificationType.allCases.map({ $0.rawValue })
let notifs: [Notification] = try await client.get(endpoint: Notifications.notifications(minId: nil, maxId: nil, types: nil, limit: lastId != nil ? notifLimit : 30))
guard !notifs.isEmpty else { return }

View File

@ -29,19 +29,16 @@ struct PostDetailsView: View {
VStack(alignment: .leading) {
if statuses.isEmpty {
statusPost(detailedStatus)
// Spacer()
} else {
ForEach(statuses) { status in
if status.id == detailedStatus.id {
statusPost(detailedStatus)
.padding(.horizontal, 15)
.padding(statuses.first!.id == detailedStatus.id ? .bottom : .vertical)
.onAppear {
proxy.scrollTo("\(detailedStatus.id)@\(detailedStatus.account.id)", anchor: .bottom)
}
} else {
CompactPostView(status: status, navigator: navigator)
CompactPostView(status: status)
.environment(navigator)
}
}
}
@ -118,6 +115,9 @@ struct PostDetailsView: View {
stats.padding(.top, 5)
}
}
.environment(navigator)
.padding(.horizontal, 15)
.padding(statuses.first!.id == detailedStatus.id ? .bottom : .vertical)
}
private func fetchStatusDetail() async {

View File

@ -8,7 +8,6 @@ struct PostingView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@Environment(AccountManager.self) private var accountManager: AccountManager
@Environment(Navigator.self) private var navigator: Navigator
public var initialString: String = ""
public var replyId: String? = nil
@ -37,14 +36,27 @@ struct PostingView: View {
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
ViewThatFits {
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
}
ScrollView(.vertical, showsIndicators: false) {
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
}
}
}
} else {
loading
.background(Color.appBackground)
@ -175,13 +187,15 @@ struct PostingView: View {
let isEdit: Bool = editId != nil
let endp: Endpoint = isEdit ? Statuses.editStatus(id: editId!, json: json) : Statuses.postStatus(json: json)
let newStatus: Status = isEdit ? try await client.put(endpoint: endp) : try await client.post(endpoint: endp)
let _: Status = isEdit ? try await client.put(endpoint: endp) : try await client.post(endpoint: endp)
postingStatus = false
HapticManager.playHaptics(haptics: Haptic.success)
dismiss()
navigator.removeSettingsOfPath()
navigator.navigate(to: .post(status: newStatus))
if isEdit {
dismiss()
}
}
}
}
@ -205,7 +219,7 @@ struct PostingView: View {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(minWidth: mediaContainers.count == 1 ? nil : containerWidth, maxWidth: 500)
.frame(minWidth: mediaContainers.count == 1 ? nil : containerWidth, maxWidth: 450)
.overlay(
RoundedRectangle(cornerRadius: 15)
.stroke(.gray.opacity(0.3), lineWidth: 1)

View File

@ -57,7 +57,7 @@ struct TimelineView: View {
ForEach(statuses!, id: \.id) { status in
LazyVStack(alignment: .leading, spacing: 2) {
CompactPostView(status: status, navigator: navigator)
CompactPostView(status: status)
.onDisappear {
guard statuses != nil else { return }
lastSeen = statuses!.firstIndex(where: { $0.id == status.id })
@ -84,6 +84,7 @@ struct TimelineView: View {
}
.padding(.top)
.background(Color.appBackground)
.environment(navigator)
.withAppRouter(navigator)
} else {
ZStack {