diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index 8d5b967e8..a800b6c74 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -295,6 +295,7 @@ 6DEB0FFB2A4F87BF007CAB99 /* User Moderator View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEB0FFA2A4F87BF007CAB99 /* User Moderator View.swift */; }; 6DFF50432A48DED3001E648D /* Inbox View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFF50422A48DED3001E648D /* Inbox View.swift */; }; 6DFF50452A48E373001E648D /* GetPrivateMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFF50442A48E373001E648D /* GetPrivateMessages.swift */; }; + 6FB4A4DE2B47860B00A7CD82 /* CollapsedCommentReplies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FB4A4DD2B47860B00A7CD82 /* CollapsedCommentReplies.swift */; }; 88B165B82A8643F4007C9115 /* View+NavigationBarColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B165B72A8643F4007C9115 /* View+NavigationBarColor.swift */; }; AD1B0D352A5F63F60006F554 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B0D342A5F63F60006F554 /* AboutView.swift */; }; AD1B0D372A5F7A260006F554 /* Licenses.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1B0D362A5F7A260006F554 /* Licenses.swift */; }; @@ -847,6 +848,7 @@ 6DEB0FFA2A4F87BF007CAB99 /* User Moderator View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "User Moderator View.swift"; sourceTree = ""; }; 6DFF50422A48DED3001E648D /* Inbox View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Inbox View.swift"; sourceTree = ""; }; 6DFF50442A48E373001E648D /* GetPrivateMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPrivateMessages.swift; sourceTree = ""; }; + 6FB4A4DD2B47860B00A7CD82 /* CollapsedCommentReplies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsedCommentReplies.swift; sourceTree = ""; }; 88B165B72A8643F4007C9115 /* View+NavigationBarColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+NavigationBarColor.swift"; sourceTree = ""; }; AD1B0D342A5F63F60006F554 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; AD1B0D362A5F7A260006F554 /* Licenses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Licenses.swift; sourceTree = ""; }; @@ -2075,6 +2077,7 @@ children = ( CDE6A80C2A45EAB30062D161 /* Embedded Post.swift */, CD391F992A537EF900E213B5 /* CommentBodyView.swift */, + 6FB4A4DD2B47860B00A7CD82 /* CollapsedCommentReplies.swift */, ); path = Components; sourceTree = ""; @@ -3536,6 +3539,7 @@ 6D15D74C2A44DC240061B5CB /* Date+RelativeTime.swift in Sources */, CDA217E62A63016A00BDA173 /* ReportMessage.swift in Sources */, CD9DD8832A622A6C0044EA8E /* ReportCommentReply.swift in Sources */, + 6FB4A4DE2B47860B00A7CD82 /* CollapsedCommentReplies.swift in Sources */, CD3FBCE12A4A836000B2063F /* AllItemsFeedView.swift in Sources */, 6D91D4582A4159D8006B8F9A /* FavoriteStarButtonStyle.swift in Sources */, 63F0C7B92A0533C700A18C5D /* Add Account View.swift in Sources */, diff --git a/Mlem/API/Internal/HierarchicalComment.swift b/Mlem/API/Internal/HierarchicalComment.swift index fcecd961b..af6942e6e 100644 --- a/Mlem/API/Internal/HierarchicalComment.swift +++ b/Mlem/API/Internal/HierarchicalComment.swift @@ -21,12 +21,20 @@ class HierarchicalComment: ObservableObject { /// Indicates whether the *current* comment is collapsed. @Published var isCollapsed: Bool = false - init(comment: APICommentView, children: [HierarchicalComment], parentCollapsed: Bool, collapsed: Bool) { + init( + comment: APICommentView, + children: [HierarchicalComment], + parentCollapsed: Bool, + collapsed: Bool, + shouldCollapseChildren: Bool = false + ) { + let depth = max(0, comment.comment.path.split(separator: ".").count - 2) + self.commentView = comment self.children = children - self.depth = max(0, commentView.comment.path.split(separator: ".").count - 2) - self.isParentCollapsed = parentCollapsed - self.isCollapsed = collapsed + self.depth = depth + self.isParentCollapsed = shouldCollapseChildren && depth >= 1 || parentCollapsed + self.isCollapsed = shouldCollapseChildren && depth == 1 || collapsed self.links = comment.comment.content.parseLinks() } } @@ -161,7 +169,8 @@ extension [APICommentView] { } let identifiedComments = Dictionary(uniqueKeysWithValues: allComments.lazy.map { ($0.id, $0) }) - + let collapseChildComments = UserDefaults.standard.bool(forKey: "collapseChildComments") + /// Recursively populates child comments by looking up IDs from `childrenById` func populateChildren(_ comment: APICommentView) -> HierarchicalComment { guard let childIds = childrenById[comment.id] else { @@ -169,7 +178,8 @@ extension [APICommentView] { comment: comment, children: [], parentCollapsed: false, - collapsed: false + collapsed: false, + shouldCollapseChildren: collapseChildComments ) } @@ -177,7 +187,8 @@ extension [APICommentView] { comment: comment, children: [], parentCollapsed: false, - collapsed: false + collapsed: false, + shouldCollapseChildren: collapseChildComments ) commentWithChildren.children = childIds .compactMap { id -> HierarchicalComment? in diff --git a/Mlem/API/Models/Site/APILocalSite.swift b/Mlem/API/Models/Site/APILocalSite.swift index 45ca01c9e..33df92f6d 100644 --- a/Mlem/API/Models/Site/APILocalSite.swift +++ b/Mlem/API/Models/Site/APILocalSite.swift @@ -23,7 +23,7 @@ struct APILocalSite: Decodable { // let legalInformation: String? // let hideModlogModNames: Bool // let applicationEmailAdmins: Bool - let slurFilterRegex: String? + let slurFilterRegex: String? // let actorNameMaxLength: Int // let federationEnabled: Bool // let federationDebug: Bool diff --git a/Mlem/ContentView.swift b/Mlem/ContentView.swift index 8e0326a1d..b823de874 100644 --- a/Mlem/ContentView.swift +++ b/Mlem/ContentView.swift @@ -68,18 +68,18 @@ struct ContentView: View { } ProfileView() - .fancyTabItem(tag: TabSelection.profile) { - FancyTabBarLabel( - tag: TabSelection.profile, - customText: appState.tabDisplayName, - symbolConfiguration: .init( - symbol: FancyTabBarLabel.SymbolConfiguration.profile.symbol, - activeSymbol: FancyTabBarLabel.SymbolConfiguration.profile.activeSymbol, - remoteSymbolUrl: appState.profileTabRemoteSymbolUrl + .fancyTabItem(tag: TabSelection.profile) { + FancyTabBarLabel( + tag: TabSelection.profile, + customText: appState.tabDisplayName, + symbolConfiguration: .init( + symbol: FancyTabBarLabel.SymbolConfiguration.profile.symbol, + activeSymbol: FancyTabBarLabel.SymbolConfiguration.profile.activeSymbol, + remoteSymbolUrl: appState.profileTabRemoteSymbolUrl + ) ) - ) - .simultaneousGesture(accountSwitchLongPress) - } + .simultaneousGesture(accountSwitchLongPress) + } SearchRoot() .fancyTabItem(tag: TabSelection.search) { diff --git a/Mlem/Icons.swift b/Mlem/Icons.swift index b3333ad2e..70ad41b3e 100644 --- a/Mlem/Icons.swift +++ b/Mlem/Icons.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI /// SFSymbol names for icons -struct Icons { +enum Icons { // votes static let votes: String = "arrow.up.arrow.down.square" static let upvote: String = "arrow.up" diff --git a/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift b/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift index b48bf10e8..6e00d502c 100644 --- a/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift +++ b/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift @@ -98,7 +98,7 @@ extension CommunityModel { functions.append(.standard(function)) functions.append(.standard(favoriteMenuFunction(callback))) } - if let instanceHost = self.communityUrl.host() { + if let instanceHost = communityUrl.host() { let instance: InstanceModel? if let site { instance = .init(from: site) diff --git a/Mlem/Models/Content/Instance/InstanceModel.swift b/Mlem/Models/Content/Instance/InstanceModel.swift index b679ff9f3..1bd7b68c1 100644 --- a/Mlem/Models/Content/Instance/InstanceModel.swift +++ b/Mlem/Models/Content/Instance/InstanceModel.swift @@ -20,33 +20,33 @@ struct InstanceModel { var slurFilterRegex: Regex? init(from response: SiteResponse) { - self.update(with: response) + update(with: response) } init(from site: APISite) { - self.update(with: site) + update(with: site) } mutating func update(with response: SiteResponse) { - self.administrators = response.admins.map { + administrators = response.admins.map { var user = UserModel(from: $0) user.usesExternalData = true user.isAdmin = true return user } - self.version = SiteVersion(response.version) + version = SiteVersion(response.version) let localSite = response.siteView.localSite do { if let regex = localSite.slurFilterRegex { - self.slurFilterRegex = try .init(regex) + slurFilterRegex = try .init(regex) } } catch { print("Invalid slur filter regex") } - self.update(with: response.siteView.site) + update(with: response.siteView.site) } mutating func update(with site: APISite) { @@ -82,7 +82,7 @@ extension InstanceModel: Identifiable { extension InstanceModel: Hashable { static func == (lhs: InstanceModel, rhs: InstanceModel) -> Bool { - return lhs.hashValue == rhs.hashValue + lhs.hashValue == rhs.hashValue } /// Hashes all fields for which state changes should trigger view updates. diff --git a/Mlem/Models/Content/Post/PostModel+MenuFunctions.swift b/Mlem/Models/Content/Post/PostModel+MenuFunctions.swift index f9539c7ad..f94654f90 100644 --- a/Mlem/Models/Content/Post/PostModel+MenuFunctions.swift +++ b/Mlem/Models/Content/Post/PostModel+MenuFunctions.swift @@ -82,7 +82,6 @@ extension PostModel { await self.delete() } }) - } // Share diff --git a/Mlem/Models/Content/User/UserModel+MenuFunctions.swift b/Mlem/Models/Content/User/UserModel+MenuFunctions.swift index 7b6c40b10..c064d5196 100644 --- a/Mlem/Models/Content/User/UserModel+MenuFunctions.swift +++ b/Mlem/Models/Content/User/UserModel+MenuFunctions.swift @@ -9,7 +9,7 @@ import Foundation extension UserModel { func blockMenuFunction(_ callback: @escaping (_ item: Self) -> Void = { _ in }) -> MenuFunction { - return .standardMenuFunction( + .standardMenuFunction( text: blocked ? "Unblock" : "Block", imageName: blocked ? Icons.show : Icons.hide, destructiveActionPrompt: blocked ? nil : AppConstants.blockUserPrompt, @@ -25,7 +25,7 @@ extension UserModel { func menuFunctions(_ callback: @escaping (_ item: Self) -> Void = { _ in }) -> [MenuFunction] { var functions: [MenuFunction] = .init() - if let instanceHost = self.profileUrl.host() { + if let instanceHost = profileUrl.host() { let instance: InstanceModel? if let site { instance = .init(from: site) diff --git a/Mlem/Models/Content/User/UserModel.swift b/Mlem/Models/Content/User/UserModel.swift index 5394c54f2..9f7e66b88 100644 --- a/Mlem/Models/Content/User/UserModel.swift +++ b/Mlem/Models/Content/User/UserModel.swift @@ -73,13 +73,13 @@ struct UserModel { /// Creates a UserModel from an GetPersonDetailsResponse /// - Parameter response: GetPersonDetailsResponse to create a UserModel representation of init(from response: GetPersonDetailsResponse) { - self.update(with: response) + update(with: response) } /// Creates a UserModel from an APIPersonView /// - Parameter apiPersonView: APIPersonView to create a UserModel representation of init(from personView: APIPersonView) { - self.update(with: personView) + update(with: personView) } /// Creates a UserModel from an APIPerson. Note that using this initialiser nullifies count values, since @@ -90,55 +90,55 @@ struct UserModel { } mutating func update(with response: GetPersonDetailsResponse) { - self.moderatedCommunities = response.moderates.map { CommunityModel(from: $0.community) } - self.update(with: response.personView) + moderatedCommunities = response.moderates.map { CommunityModel(from: $0.community) } + update(with: response.personView) } mutating func update(with personView: APIPersonView) { - self.postCount = personView.counts.postCount - self.commentCount = personView.counts.commentCount + postCount = personView.counts.postCount + commentCount = personView.counts.commentCount // TODO: 0.18 Deprecation @Dependency(\.siteInformation) var siteInformation if (siteInformation.version ?? .infinity) > .init("0.19.0") { - self.isAdmin = personView.isAdmin + isAdmin = personView.isAdmin } - self.update(with: personView.person) + update(with: personView.person) } mutating func update(with person: APIPerson) { self.person = person - self.userId = person.id - self.name = person.name - self.displayName = person.displayName ?? person.name - self.bio = person.bio + userId = person.id + name = person.name + displayName = person.displayName ?? person.name + bio = person.bio - self.avatar = person.avatarUrl - self.banner = person.bannerUrl + avatar = person.avatarUrl + banner = person.bannerUrl - self.banned = person.banned - self.local = person.local - self.deleted = person.deleted - self.isBot = person.botAccount + banned = person.banned + local = person.local + deleted = person.deleted + isBot = person.botAccount - self.isAdmin = person.admin + isAdmin = person.admin - self.creationDate = person.published - self.updatedDate = person.updated - self.banExpirationDate = person.banExpires + creationDate = person.published + updatedDate = person.updated + banExpirationDate = person.banExpires - self.instanceId = person.instanceId - self.matrixUserId = person.matrixUserId + instanceId = person.instanceId + matrixUserId = person.matrixUserId - self.profileUrl = person.actorId - self.sharedInboxUrl = person.sharedInboxLink + profileUrl = person.actorId + sharedInboxUrl = person.sharedInboxLink // Annoyingly, PersonView doesn't include whether the user is blocked so we can't // actually determine this without making extra requests... - if self.blocked == nil { - self.blocked = false + if blocked == nil { + blocked = false } } @@ -181,7 +181,7 @@ struct UserModel { } do { let response = try await personRepository.updateBlocked(for: userId, blocked: blocked) - self.blocked = response.blocked + blocked = response.blocked RunLoop.main.perform { [self] in callback(self) } @@ -192,15 +192,15 @@ struct UserModel { } static func mock() -> UserModel { - return self.init(from: APIPerson.mock()) + self.init(from: APIPerson.mock()) } var isActiveAccount: Bool { - return siteInformation.myUserInfo?.localUserView.person.id == userId + siteInformation.myUserInfo?.localUserView.person.id == userId } var fullyQualifiedUsername: String? { - if let host = self.profileUrl.host() { + if let host = profileUrl.host() { return "\(name!)@\(host)" } return nil @@ -227,7 +227,7 @@ extension UserModel: Identifiable { extension UserModel: Hashable { static func == (lhs: UserModel, rhs: UserModel) -> Bool { - return lhs.hashValue == rhs.hashValue + lhs.hashValue == rhs.hashValue } /// Hashes all fields for which state changes should trigger view updates. diff --git a/Mlem/Models/Trackers/SiteInformationTracker.swift b/Mlem/Models/Trackers/SiteInformationTracker.swift index daa7af972..c70abd764 100644 --- a/Mlem/Models/Trackers/SiteInformationTracker.swift +++ b/Mlem/Models/Trackers/SiteInformationTracker.swift @@ -21,7 +21,6 @@ class SiteInformationTracker: ObservableObject { @Published var myUserInfo: APIMyUserInfo? func load(account: SavedAccount) { - version = account.siteVersion Task { do { diff --git a/Mlem/Repositories/PersonRepository.swift b/Mlem/Repositories/PersonRepository.swift index fdf1e0bdd..5cf93e6ef 100644 --- a/Mlem/Repositories/PersonRepository.swift +++ b/Mlem/Repositories/PersonRepository.swift @@ -54,7 +54,7 @@ class PersonRepository { func loadUserDetails(for url: URL, limit: Int, savedOnly: Bool = false) async throws -> GetPersonDetailsResponse { let result = try await apiClient.resolve(query: url.absoluteString) switch result { - case .person(let person): + case let .person(person): return try await loadUserDetails(for: person.person.id, limit: limit, savedOnly: savedOnly) default: throw PersonRequestError.notFound diff --git a/Mlem/Views/Shared/BadgeView.swift b/Mlem/Views/Shared/BadgeView.swift index 870f21d29..c726c39c5 100644 --- a/Mlem/Views/Shared/BadgeView.swift +++ b/Mlem/Views/Shared/BadgeView.swift @@ -76,19 +76,19 @@ struct BadgeView: View { self.label = "Unsupported Badge" if let host = url.host(), host == "img.shields.io" { let path = url.pathComponents - self.decodeBadgeType(path) - self.decodeLabel(path[2]) + decodeBadgeType(path) + decodeLabel(path[2]) if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { if let parameters = components.queryItems { for parameter in parameters { switch parameter.name { case "logo": if let value = parameter.value { - self.decodeLogo(name: value) + decodeLogo(name: value) } case "color": if let value = parameter.value { - self.decodeColor(value) + decodeColor(value) } default: break @@ -108,23 +108,23 @@ struct BadgeView: View { mutating func decodeBadgeType(_ path: [String]) { switch path[1] { case "mastodon": - self.label = "Follow on Mastodon" - self.color = .init(Color(hex: "6364FF"), text: .white) - self.logo = .bundle("mastodon.logo") + label = "Follow on Mastodon" + color = .init(Color(hex: "6364FF"), text: .white) + logo = .bundle("mastodon.logo") case "discord": - self.label = "Join Discord Server" - self.color = .init(Color(hex: "5865F2"), text: .white) - self.logo = .bundle("discord.logo") + label = "Join Discord Server" + color = .init(Color(hex: "5865F2"), text: .white) + logo = .bundle("discord.logo") case "matrix": - self.label = "Join Matrix Room" - self.color = .init(.black, outline: .white, text: .white) - self.logo = .bundle("matrix.logo") + label = "Join Matrix Room" + color = .init(.black, outline: .white, text: .white) + logo = .bundle("matrix.logo") case "github": - self.label = "Github" - self.color = .init(.black, outline: .white, text: .white) - self.logo = .bundle("github.logo") + label = "Github" + color = .init(.black, outline: .white, text: .white) + logo = .bundle("github.logo") case "lemmy": - self.label = path[2] + label = path[2] default: break } @@ -133,20 +133,20 @@ struct BadgeView: View { mutating func decodeLabel(_ text: String) { let parts = text.replacingOccurrences(of: "_", with: " ").split(separator: "-") if parts.count == 3 { - self.label = String(parts[0]) - self.message = String(parts[1]) - self.decodeColor(String(parts[2])) + label = String(parts[0]) + message = String(parts[1]) + decodeColor(String(parts[2])) } else if parts.count == 2 { - self.label = String(parts[0]) - self.decodeColor(String(parts[1])) + label = String(parts[0]) + decodeColor(String(parts[1])) } } mutating func decodeColor(_ text: String) { if color == nil { - self.color = BadgeView.colorNameMap[text] - if self.color == nil { - self.color = .init(Color(hex: text), text: .primary) + color = BadgeView.colorNameMap[text] + if color == nil { + color = .init(Color(hex: text), text: .primary) } } } @@ -154,15 +154,15 @@ struct BadgeView: View { mutating func decodeLogo(name: String) { switch name { case "github": - self.logo = .bundle("github.logo") + logo = .bundle("github.logo") case "matrix": - self.logo = .bundle("matrix.logo") + logo = .bundle("matrix.logo") case "mastodon": - self.logo = .bundle("mastodon.logo") + logo = .bundle("mastodon.logo") case "discord": - self.logo = .bundle("discord.logo") + logo = .bundle("discord.logo") case "lemmy": - self.logo = .bundle("lemmy.logo") + logo = .bundle("lemmy.logo") default: break } @@ -172,9 +172,9 @@ struct BadgeView: View { HStack(spacing: 7) { Group { switch logo { - case .bundle(let name): + case let .bundle(name): Image(name) - case .system(let systemName): + case let .system(systemName): Image(systemName: systemName) case nil: EmptyView() @@ -200,7 +200,6 @@ struct BadgeView: View { .stroke(color?.outline ?? .secondary, lineWidth: 1) } } - } #Preview("Variants") { @@ -227,11 +226,12 @@ struct BadgeView: View { ScrollView { LazyVGrid( columns: .init(repeating: GridItem(.adaptive(minimum: 200, maximum: .infinity)), count: 2), - alignment: .leading, spacing: 10) { - ForEach(Array(BadgeView.colorNameMap.keys).sorted(by: >), id: \.self) { key in + alignment: .leading, spacing: 10 + ) { + ForEach(Array(BadgeView.colorNameMap.keys).sorted(by: >), id: \.self) { key in BadgeView(label: "Color", message: key, color: BadgeView.colorNameMap[key]!) - } } - .padding() + } + .padding() } } diff --git a/Mlem/Views/Shared/CollapsibleSection.swift b/Mlem/Views/Shared/CollapsibleSection.swift index 5c4098113..f22028ae0 100644 --- a/Mlem/Views/Shared/CollapsibleSection.swift +++ b/Mlem/Views/Shared/CollapsibleSection.swift @@ -53,7 +53,7 @@ struct CollapsibleSection: View { content() } - if let footer = footer { + if let footer { Text(footer) .textCase(.uppercase) .font(.footnote) @@ -66,5 +66,5 @@ struct CollapsibleSection: View { .clipShape(RoundedRectangle(cornerRadius: AppConstants.largeItemCornerRadius)) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 16) - } + } } diff --git a/Mlem/Views/Shared/Comments/Comment Item Logic.swift b/Mlem/Views/Shared/Comments/Comment Item Logic.swift index a99be893f..6f64eea12 100644 --- a/Mlem/Views/Shared/Comments/Comment Item Logic.swift +++ b/Mlem/Views/Shared/Comments/Comment Item Logic.swift @@ -137,12 +137,41 @@ extension CommentItem { func toggleCollapsed() { withAnimation(.showHideComment(!hierarchicalComment.isCollapsed)) { // Perhaps we want an explict flag for this in the future? - if !showPostContext { + if collapseComments, !isCommentReplyHidden, pageContext == .posts { + toggleTopLevelCommentCollapse(isCollapsed: !hierarchicalComment.isCollapsed) + } else if !showPostContext { commentTracker.setCollapsed(!hierarchicalComment.isCollapsed, comment: hierarchicalComment) } } } + /// Uncollapses HierarchicalComment and children at depth level 1 + func uncollapseComment() { + commentTracker.setCollapsed(false, comment: hierarchicalComment) + + for comment in hierarchicalComment.children where comment.depth == 1 { + commentTracker.setCollapsed(false, comment: comment) + } + } + + // Collapses the top level comment and retains the child comment collapse state + // If a user views all child comments, then collapses top level comment, the children will be uncollapsed along side top level + func toggleTopLevelCommentCollapse(isCollapsed: Bool) { + hierarchicalComment.isCollapsed = isCollapsed + + if !isCollapsed { + isCommentReplyHidden = false + } + } + + /// Collapses HierarchicalComment and children at depth level 1 + func collapseComment() { + for comment in hierarchicalComment.children where comment.depth == 1 { + commentTracker.setCollapsed(true, comment: comment) + comment.isParentCollapsed = true + } + } + // MARK: helpers // swiftlint:disable function_body_length diff --git a/Mlem/Views/Shared/Comments/Comment Item.swift b/Mlem/Views/Shared/Comments/Comment Item.swift index 5a787d41b..a68d62f12 100644 --- a/Mlem/Views/Shared/Comments/Comment Item.swift +++ b/Mlem/Views/Shared/Comments/Comment Item.swift @@ -13,6 +13,10 @@ struct CommentItem: View { case standard, never } + enum PageContext { + case posts, profile + } + @Dependency(\.apiClient) var apiClient @Dependency(\.commentRepository) var commentRepository @Dependency(\.errorHandler) var errorHandler @@ -27,6 +31,7 @@ struct CommentItem: View { @AppStorage("shouldShowSavedInCommentBar") var shouldShowSavedInCommentBar: Bool = false @AppStorage("shouldShowRepliesInCommentBar") var shouldShowRepliesInCommentBar: Bool = true @AppStorage("compactComments") var compactComments: Bool = false + @AppStorage("collapseChildComments") var collapseComments: Bool = false @AppStorage("tapCommentToCollapse") var tapCommentToCollapse: Bool = true // MARK: Temporary @@ -36,6 +41,8 @@ struct CommentItem: View { @State var dirtyScore: Int // = 0 @State var dirtySaved: Bool // = false @State var dirty: Bool = false + + @State var isCommentReplyHidden: Bool = false @State var isComposingReport: Bool = false @@ -65,6 +72,7 @@ struct CommentItem: View { let showPostContext: Bool let showCommentCreator: Bool let enableSwipeActions: Bool + let pageContext: PageContext // MARK: Destructive confirmation @@ -101,7 +109,8 @@ struct CommentItem: View { indentBehaviour: IndentBehaviour = .standard, showPostContext: Bool, showCommentCreator: Bool, - enableSwipeActions: Bool = true + enableSwipeActions: Bool = true, + pageContext: PageContext = .posts ) { self.hierarchicalComment = hierarchicalComment self.postContext = postContext @@ -109,6 +118,7 @@ struct CommentItem: View { self.showPostContext = showPostContext self.showCommentCreator = showCommentCreator self.enableSwipeActions = enableSwipeActions + self.pageContext = pageContext _dirtyVote = State(initialValue: hierarchicalComment.commentView.myVote ?? .resetVote) _dirtyScore = State(initialValue: hierarchicalComment.commentView.counts.score) @@ -185,6 +195,22 @@ struct CommentItem: View { Spacer() .frame(height: AppConstants.postAndCommentSpacing) } + + if collapseComments, + pageContext == .posts, + !hierarchicalComment.isCollapsed, + hierarchicalComment.depth == 0, + hierarchicalComment.children.count > 0, + !isCommentReplyHidden { + Divider() + HStack { + CollapsedCommentReplies(numberOfReplies: .constant(hierarchicalComment.commentView.counts.childCount)) + .onTapGesture { + isCommentReplyHidden = true + uncollapseComment() + } + } + } } } .contentShape(Rectangle()) // allow taps in blank space to register @@ -208,6 +234,15 @@ struct CommentItem: View { MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) } } + .onChange(of: collapseComments) { newValue in + if pageContext == .posts { + if newValue == false { + uncollapseComment() + } else { + collapseComment() + } + } + } } // swiftlint:enable function_body_length } diff --git a/Mlem/Views/Shared/Comments/Components/CollapsedCommentReplies.swift b/Mlem/Views/Shared/Comments/Components/CollapsedCommentReplies.swift new file mode 100644 index 000000000..a217785fa --- /dev/null +++ b/Mlem/Views/Shared/Comments/Components/CollapsedCommentReplies.swift @@ -0,0 +1,31 @@ +// +// CollapsedCommentReplies.swift +// Mlem +// +// Created by Sumeet Gill on 2024-01-04. +// + +import SwiftUI + +struct CollapsedCommentReplies: View { + @Binding var numberOfReplies: Int + var lineWidth: CGFloat = 2 + + var body: some View { + HStack { + Rectangle() + .border(width: lineWidth, edges: [.leading], color: .accentColor) + .frame(width: lineWidth) + Image(systemName: Icons.replies) + Text("Show ^[\(numberOfReplies) Reply](inflect: true)") + .foregroundStyle(.blue) + .padding(.vertical, 10) + } + .frame(maxHeight: 50) + .padding(.leading, 10) + } +} + +#Preview { + CollapsedCommentReplies(numberOfReplies: .constant(1)) +} diff --git a/Mlem/Views/Shared/Components/Image Upload/LinkAttachmentModel.swift b/Mlem/Views/Shared/Components/Image Upload/LinkAttachmentModel.swift index 5da57fb92..3dea8e042 100644 --- a/Mlem/Views/Shared/Components/Image Upload/LinkAttachmentModel.swift +++ b/Mlem/Views/Shared/Components/Image Upload/LinkAttachmentModel.swift @@ -5,16 +5,16 @@ // Created by Sjmarf on 17/12/2023. // -import SwiftUI import Dependencies import PhotosUI +import SwiftUI class LinkAttachmentModel: ObservableObject { @Dependency(\.pictrsRepository) private var pictrsRepository: PictrsRespository @Dependency(\.apiClient) private var apiClient: APIClient @Dependency(\.errorHandler) private var errorHandler: ErrorHandler - var uploadTask: Task<(), any Error>? + var uploadTask: Task? @AppStorage("promptUser.permission.privacy.allowImageUploads") var askedForPermissionToUploadImages: Bool = false @AppStorage("confirmImageUploads") var confirmImageUploads: Bool = false @@ -64,7 +64,7 @@ class LinkAttachmentModel: ObservableObject { func prepareToUpload(result: Result) { switch result { - case .success(let url): + case let .success(url): do { guard url.startAccessingSecurityScopedResource() else { imageModel = .init(state: .failed("Invalid permissions")) @@ -77,21 +77,21 @@ class LinkAttachmentModel: ObservableObject { url.stopAccessingSecurityScopedResource() imageModel = .init(state: .failed(String(describing: error))) } - case .failure(let error): + case let .failure(error): imageModel = .init(state: .failed(String(describing: error))) } } func prepareToUpload(data: Data) { imageModel = .init() - self.imageModel?.state = .readyToUpload(data: data) + imageModel?.state = .readyToUpload(data: data) if let uiImage = UIImage(data: data) { - self.imageModel?.image = Image(uiImage: uiImage) + imageModel?.image = Image(uiImage: uiImage) } - if self.askedForPermissionToUploadImages == false || self.confirmImageUploads { - self.showingUploadConfirmation = true + if askedForPermissionToUploadImages == false || confirmImageUploads { + showingUploadConfirmation = true } else { - self.uploadImage() + uploadImage() } } @@ -112,7 +112,7 @@ class LinkAttachmentModel: ObservableObject { } func uploadImage() { - guard let imageModel = imageModel else { return } + guard let imageModel else { return } Task(priority: .userInitiated) { self.uploadTask = try await pictrsRepository.uploadImage( imageModel: imageModel, @@ -120,8 +120,8 @@ class LinkAttachmentModel: ObservableObject { DispatchQueue.main.async { self.imageModel = newValue switch newValue.state { - case .uploaded(let file): - if let file = file { + case let .uploaded(file): + if let file { do { var components = URLComponents() components.scheme = try self.apiClient.session.instanceUrl.scheme @@ -142,13 +142,13 @@ class LinkAttachmentModel: ObservableObject { } func deletePictrs(compareUrl: String? = nil) { - if let task = self.uploadTask { + if let task = uploadTask { task.cancel() } - self.photosPickerItem = nil - switch self.imageModel?.state { - case .uploaded(file: let file): - if let file = file { + photosPickerItem = nil + switch imageModel?.state { + case let .uploaded(file: file): + if let file { Task { do { if let compareUrl { @@ -177,10 +177,10 @@ class LinkAttachmentModel: ObservableObject { } } default: - self.imageModel = nil + imageModel = nil } - if url == "" && self.imageModel != nil { - self.imageModel = nil + if url == "", imageModel != nil { + imageModel = nil } } } diff --git a/Mlem/Views/Shared/Composer/PostComposerView.swift b/Mlem/Views/Shared/Composer/PostComposerView.swift index beac890a5..e4a152d01 100644 --- a/Mlem/Views/Shared/Composer/PostComposerView.swift +++ b/Mlem/Views/Shared/Composer/PostComposerView.swift @@ -77,9 +77,9 @@ struct PostComposerView: View { VStack(spacing: 0) { // Community Row headerView - .padding(.bottom, 15) - .padding(.horizontal) - .zIndex(1) + .padding(.bottom, 15) + .padding(.horizontal) + .zIndex(1) VStack(spacing: 15) { TextField("Title", text: $postTitle, axis: .vertical) diff --git a/Mlem/Views/Shared/Error View.swift b/Mlem/Views/Shared/Error View.swift index 9634e8f1c..d601820a5 100644 --- a/Mlem/Views/Shared/Error View.swift +++ b/Mlem/Views/Shared/Error View.swift @@ -5,9 +5,9 @@ // Created by David Bureš on 19.06.2022. // +import Combine import SwiftUI import UniformTypeIdentifiers -import Combine struct ErrorView: View { @AppStorage("developerMode") var developerMode: Bool = false @@ -81,14 +81,13 @@ struct ErrorView: View { } } - if errorDetails.error != nil && (errorDetails.title == nil || developerMode) { + if errorDetails.error != nil, errorDetails.title == nil || developerMode { Button("Show details") { showingFullError.toggle() } .buttonStyle(.plain) .foregroundStyle(.tertiary) } - } .padding() .foregroundColor(.secondary) @@ -118,8 +117,10 @@ struct ErrorView: View { .foregroundStyle(.red) Divider() Button { - UIPasteboard.general.setValue(errorText, - forPasteboardType: UTType.plainText.identifier) + UIPasteboard.general.setValue( + errorText, + forPasteboardType: UTType.plainText.identifier + ) } label: { Label("Copy", systemImage: "square.on.square") } diff --git a/Mlem/Views/Shared/Instance/InstanceView.swift b/Mlem/Views/Shared/Instance/InstanceView.swift index 5380644c6..9bb4a89c6 100644 --- a/Mlem/Views/Shared/Instance/InstanceView.swift +++ b/Mlem/Views/Shared/Instance/InstanceView.swift @@ -5,9 +5,9 @@ // Created by Sjmarf on 13/01/2024. // -import SwiftUI import Charts import Dependencies +import SwiftUI enum InstanceViewTab: String, Identifiable, CaseIterable { case about, administrators, statistics, uptime, safety @@ -101,7 +101,7 @@ struct InstanceView: View { } Divider() } - switch self.selectedTab { + switch selectedTab { case .about: if let description = instance.description { MarkdownView(text: description, isNsfw: false) @@ -152,17 +152,17 @@ struct InstanceView: View { instance.update(with: info) self.instance = instance } else { - self.instance = InstanceModel(from: info) + instance = InstanceModel(from: info) } } } } else { errorDetails = ErrorDetails(title: "\"\(domainName)\" is an invalid URL.") } - } catch APIClientError.decoding(let data, let error) { + } catch let APIClientError.decoding(data, error) { withAnimation(.easeOut(duration: 0.2)) { if let content = String(data: data, encoding: .utf8), - content.contains("Error 404 - \(domainName)" ) { + content.contains("Error 404 - \(domainName)") { errorDetails = ErrorDetails( title: "KBin Instance", body: "We can't yet display KBin details.", diff --git a/Mlem/Views/Shared/Links/AvatarView.swift b/Mlem/Views/Shared/Links/AvatarView.swift index 8ad83938e..fa81ca059 100644 --- a/Mlem/Views/Shared/Links/AvatarView.swift +++ b/Mlem/Views/Shared/Links/AvatarView.swift @@ -39,8 +39,7 @@ struct AvatarView: View { self.lineWidth = lineWidth self.blurAvatar = shouldBlurNsfw && blurAvatar switch iconResolution { - - case .fixed(let pixels): + case let .fixed(pixels): self.url = url?.withIconSize(pixels) case .unrestricted: self.url = url diff --git a/Mlem/Views/Shared/Markdown View.swift b/Mlem/Views/Shared/Markdown View.swift index c3ccf85fd..ca30f0ec6 100644 --- a/Mlem/Views/Shared/Markdown View.swift +++ b/Mlem/Views/Shared/Markdown View.swift @@ -69,13 +69,13 @@ struct MarkdownView: View { @ViewBuilder func renderBlock(block: MarkdownBlock) -> some View { switch block.type { - case .text(let text): + case let .text(text): renderAsMarkdown(text: text, theme: theme) - case .image(url: let imageUrl): + case let .image(url: imageUrl): if let imageUrl = URL(string: imageUrl) { imageView(url: imageUrl, blockId: block.id) } - case .linkedImage(imageUrl: let imageUrl, linkUrl: let linkUrl): + case let .linkedImage(imageUrl: imageUrl, linkUrl: linkUrl): if let imageUrl = URL(string: imageUrl), let linkUrl = URL(string: linkUrl) { Link(destination: linkUrl) { imageView(url: imageUrl, blockId: block.id, shouldExpand: false) @@ -91,12 +91,13 @@ struct MarkdownView: View { return AnyView( BadgeView(url: url) .padding(.vertical, 4) - ) + ) } else if !MarkdownView.hiddenImageDomains.contains(host) { if replaceImagesWithEmoji { return AnyView(renderAsMarkdown( text: AppConstants.pictureEmoji[blockId % AppConstants.pictureEmoji.count], - theme: theme) + theme: theme + ) ) } else { return AnyView( @@ -136,7 +137,7 @@ struct MarkdownView: View { var blocks: [MarkdownBlock] = [] if let firstImage = try imageLinkLooker.firstMatch(in: text) { if firstImage.range.lowerBound != .init(utf16Offset: 0, in: text) { - blocks.append(contentsOf: try parseMarkdownForImages(text: String(text[.. = .init() func deleteSwipeAction(_ item: AnyContentModel) -> SwipeAction { - return SwipeAction( + SwipeAction( symbol: .init(emptyName: Icons.close, fillName: Icons.close), color: .red, action: { @@ -120,7 +120,6 @@ struct RecentSearchesView: View { } struct RecentSearchesViewPreview: View { - @StateObject var appState: AppState = .init() @StateObject var recentSearchesTracker: RecentSearchesTracker = .init() diff --git a/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift b/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift index 861f5bbf3..2bb6385fc 100644 --- a/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift +++ b/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift @@ -12,7 +12,7 @@ enum CommunityComplication: CaseIterable { case type, instance, subscribers } -extension Array where Element == CommunityComplication { +extension [CommunityComplication] { static let withTypeLabel: [CommunityComplication] = [.type, .instance, .subscribers] static let withoutTypeLabel: [CommunityComplication] = [.instance, .subscribers] static let instanceOnly: [CommunityComplication] = [.instance] diff --git a/Mlem/Views/Tabs/Search/Results/UserResultView.swift b/Mlem/Views/Tabs/Search/Results/UserResultView.swift index f1b47c00a..6d8bffc1e 100644 --- a/Mlem/Views/Tabs/Search/Results/UserResultView.swift +++ b/Mlem/Views/Tabs/Search/Results/UserResultView.swift @@ -12,7 +12,7 @@ enum UserComplication: CaseIterable { case type, instance, date, posts, comments } -extension Array where Element == UserComplication { +extension [UserComplication] { static let withTypeLabel: [UserComplication] = [.type, .instance, .comments] static let withoutTypeLabel: [UserComplication] = [.instance, .date, .posts, .comments] static let instanceOnly: [UserComplication] = [.instance] diff --git a/Mlem/Views/Tabs/Search/SearchResultListView.swift b/Mlem/Views/Tabs/Search/SearchResultListView.swift index 5722c27b5..9effba006 100644 --- a/Mlem/Views/Tabs/Search/SearchResultListView.swift +++ b/Mlem/Views/Tabs/Search/SearchResultListView.swift @@ -83,7 +83,6 @@ struct SearchResultListView: View { } struct SearchResultsListViewPreview: View { - @StateObject var searchModel: SearchModel = .init() @StateObject var contentTracker: ContentTracker = .init() @StateObject var recentSearchesTracker: RecentSearchesTracker = .init() diff --git a/Mlem/Views/Tabs/Settings/Components/AccountListView.swift b/Mlem/Views/Tabs/Settings/Components/AccountListView.swift index 2b6a71576..4f122878b 100644 --- a/Mlem/Views/Tabs/Settings/Components/AccountListView.swift +++ b/Mlem/Views/Tabs/Settings/Components/AccountListView.swift @@ -5,8 +5,8 @@ // Created by Sjmarf on 22/12/2023. // -import SwiftUI import Dependencies +import SwiftUI struct QuickSwitcherView: View { var body: some View { @@ -54,7 +54,7 @@ struct AccountListView: View { var body: some View { Group { if !isSwitching { - if accountsTracker.savedAccounts.count > 3 && groupAccountSort { + if accountsTracker.savedAccounts.count > 3, groupAccountSort { ForEach(Array(accountGroups.enumerated()), id: \.offset) { offset, group in Section { ForEach(group.accounts, id: \.self) { account in @@ -101,7 +101,7 @@ struct AccountListView: View { if let text { Text(text) } - if !isQuickSwitcher && accountsTracker.savedAccounts.count > 2 { + if !isQuickSwitcher, accountsTracker.savedAccounts.count > 2 { Spacer() sortModeMenu() } diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Account/AccountSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Account/AccountSettingsView.swift index a1a0ca02a..9af6aaa9c 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Account/AccountSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Account/AccountSettingsView.swift @@ -5,8 +5,8 @@ // Created by Sjmarf on 22/11/2023. // -import SwiftUI import Dependencies +import SwiftUI struct AccountSettingsView: View { @Dependency(\.siteInformation) var siteInformation: SiteInformationTracker @@ -24,13 +24,12 @@ struct AccountSettingsView: View { init() { if let info = siteInformation.myUserInfo { - displayName = info.localUserView.person.displayName ?? "" - showNsfw = info.localUserView.localUser.showNsfw + self.displayName = info.localUserView.person.displayName ?? "" + self.showNsfw = info.localUserView.localUser.showNsfw } } var body: some View { - Form { if let info = siteInformation.myUserInfo { Section { @@ -70,11 +69,11 @@ struct AccountSettingsView: View { } NavigationLink(.settings(.accountGeneral)) { Label("Content & Notifications", systemImage: "list.bullet.rectangle.fill") - .labelStyle(SquircleLabelStyle(color: .orange)) + .labelStyle(SquircleLabelStyle(color: .orange)) } NavigationLink(.settings(.accountAdvanced)) { Label("Advanced", systemImage: "gearshape.2.fill") - .labelStyle(SquircleLabelStyle(color: .gray)) + .labelStyle(SquircleLabelStyle(color: .gray)) } } footer: { if settingsDisabled { @@ -98,7 +97,6 @@ struct AccountSettingsView: View { Section { Button("Sign Out", role: .destructive) { showingSignOutConfirmation = true - } .frame(maxWidth: .infinity) .confirmationDialog("Really sign out?", isPresented: $showingSignOutConfirmation) { @@ -123,7 +121,7 @@ struct AccountSettingsView: View { Button("Delete Account", role: .destructive) { accountForDeletion = appState.currentActiveAccount } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity) } } else { diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Comment/CommentSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Comment/CommentSettingsView.swift index 29fde9586..df0674e66 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Comment/CommentSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Comment/CommentSettingsView.swift @@ -51,6 +51,7 @@ struct CommentSettingsView: View { @AppStorage("shouldShowUserServerInComment") var shouldShowUserServerInComment: Bool = false @AppStorage("showCommentJumpButton") var showCommentJumpButton: Bool = true + @AppStorage("collapseChildComments") var collapseChildComments: Bool = false @AppStorage("commentJumpButtonSide") var commentJumpButtonSide: JumpButtonLocation = .bottomTrailing var body: some View { @@ -61,6 +62,11 @@ struct CommentSettingsView: View { settingName: "Compact Comments", isTicked: $compactComments ) + SwitchableSettingsItem( + settingPictureSystemName: Icons.collapseComments, + settingName: "Collapse Comments", + isTicked: $collapseChildComments + ) NavigationLink(.commentSettings(.layoutWidget)) { Label { diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Icons/AlternativeIconLabel.swift b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Icons/AlternativeIconLabel.swift index 820babe5b..4795284de 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Icons/AlternativeIconLabel.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Icons/AlternativeIconLabel.swift @@ -22,15 +22,15 @@ struct AlternativeIconLabel: View { .padding(3) .shadow(radius: 2, x: 0, y: 2) .overlay { - if selected { - ZStack { - RoundedRectangle(cornerRadius: AppConstants.appIconCornerRadius) - .stroke(Color(.secondarySystemBackground), lineWidth: 5) - .padding(2) - RoundedRectangle(cornerRadius: AppConstants.appIconCornerRadius + 2) - .stroke(.blue, lineWidth: 3) - } + if selected { + ZStack { + RoundedRectangle(cornerRadius: AppConstants.appIconCornerRadius) + .stroke(Color(.secondarySystemBackground), lineWidth: 5) + .padding(2) + RoundedRectangle(cornerRadius: AppConstants.appIconCornerRadius + 2) + .stroke(.blue, lineWidth: 3) } + } } Text(icon.name) .multilineTextAlignment(.center)