diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index 11b9c668f..7ee50a143 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -71,6 +71,10 @@ 03C897F82ABF652D005F3403 /* SearchRoot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C897F72ABF652D005F3403 /* SearchRoot.swift */; }; 03C898012AC04EF9005F3403 /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C898002AC04EF9005F3403 /* SearchResultsView.swift */; }; 03C898032AC04F61005F3403 /* RecentSearchesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C898022AC04F61005F3403 /* RecentSearchesView.swift */; }; + 03C905C82B3C70E200B9082F /* UserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C905C72B3C70E200B9082F /* UserView.swift */; }; + 03C905CA2B3C834C00B9082F /* UserHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C905C92B3C834C00B9082F /* UserHeaderView.swift */; }; + 03C905CC2B3C88F700B9082F /* SearchTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C905CB2B3C88F700B9082F /* SearchTab.swift */; }; + 03C905CE2B3C8DC400B9082F /* UserView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C905CD2B3C8DC400B9082F /* UserView+Logic.swift */; }; 03CB329E2A6D8E910021EF27 /* PostComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CB329D2A6D8E910021EF27 /* PostComposerView.swift */; }; 03E0B9C82A61F0F400FED265 /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0B9C72A61F0F400FED265 /* AdvancedSettingsView.swift */; }; 03E0B9CA2A62B4A400FED265 /* ContributorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0B9C92A62B4A400FED265 /* ContributorsView.swift */; }; @@ -82,7 +86,7 @@ 03EC92972AC069CE007BBE7E /* SearchResultListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC92962AC069CE007BBE7E /* SearchResultListView.swift */; }; 03EC92992AC0BF8A007BBE7E /* APIClient+Pictrs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC92982AC0BF8A007BBE7E /* APIClient+Pictrs.swift */; }; 03EEEAF32AB8DCDF0087F8D8 /* CommunityResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EEEAF22AB8DCDF0087F8D8 /* CommunityResultView.swift */; }; - 03EEEAF72AB8ED3C0087F8D8 /* SearchTabPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EEEAF62AB8ED3C0087F8D8 /* SearchTabPicker.swift */; }; + 03EEEAF72AB8ED3C0087F8D8 /* BubblePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EEEAF62AB8ED3C0087F8D8 /* BubblePicker.swift */; }; 03EEEAF92ABB985D0087F8D8 /* CommunityModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EEEAF82ABB985D0087F8D8 /* CommunityModel.swift */; }; 03F4DC9D2B193F4C00556C67 /* MatrixLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F4DC9C2B193F4C00556C67 /* MatrixLinkView.swift */; }; 03F4DC9F2B1A8AD500556C67 /* SignInAndSecuritySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F4DC9E2B1A8AD500556C67 /* SignInAndSecuritySettingsView.swift */; }; @@ -251,7 +255,7 @@ 6386E03A2A0455BC006B3C1D /* String+Contains.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6386E0392A0455BC006B3C1D /* String+Contains.swift */; }; 6386E0402A045723006B3C1D /* Website Icon Complex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6386E03F2A045723006B3C1D /* Website Icon Complex.swift */; }; 63A09B69285F53E9004F0032 /* Error View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A09B68285F53E9004F0032 /* Error View.swift */; }; - 63ABCE0E27F894060047A7D0 /* User View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63ABCE0D27F894060047A7D0 /* User View.swift */; }; + 63CE4E732A06F5A100405271 /* Access Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63CE4E722A06F5A100405271 /* Access Token.swift */; }; 63D24ED92A169A5F005CCA81 /* UIApplication+FirstKeyWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63D24ED82A169A5F005CCA81 /* UIApplication+FirstKeyWindow.swift */; }; 63D24EDC2A169F12005CCA81 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 63D24EDB2A169F12005CCA81 /* MarkdownUI */; }; 63D24EDE2A169F2A005CCA81 /* Markdown View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63D24EDD2A169F2A005CCA81 /* Markdown View.swift */; }; @@ -611,6 +615,10 @@ 03C897F72ABF652D005F3403 /* SearchRoot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRoot.swift; sourceTree = ""; }; 03C898002AC04EF9005F3403 /* SearchResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = ""; }; 03C898022AC04F61005F3403 /* RecentSearchesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentSearchesView.swift; sourceTree = ""; }; + 03C905C72B3C70E200B9082F /* UserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserView.swift; sourceTree = ""; }; + 03C905C92B3C834C00B9082F /* UserHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserHeaderView.swift; sourceTree = ""; }; + 03C905CB2B3C88F700B9082F /* SearchTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTab.swift; sourceTree = ""; }; + 03C905CD2B3C8DC400B9082F /* UserView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserView+Logic.swift"; sourceTree = ""; }; 03CB329D2A6D8E910021EF27 /* PostComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostComposerView.swift; sourceTree = ""; }; 03E0B9C72A61F0F400FED265 /* AdvancedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsView.swift; sourceTree = ""; }; 03E0B9C92A62B4A400FED265 /* ContributorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContributorsView.swift; sourceTree = ""; }; @@ -622,7 +630,7 @@ 03EC92962AC069CE007BBE7E /* SearchResultListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultListView.swift; sourceTree = ""; }; 03EC92982AC0BF8A007BBE7E /* APIClient+Pictrs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIClient+Pictrs.swift"; sourceTree = ""; }; 03EEEAF22AB8DCDF0087F8D8 /* CommunityResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityResultView.swift; sourceTree = ""; }; - 03EEEAF62AB8ED3C0087F8D8 /* SearchTabPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabPicker.swift; sourceTree = ""; }; + 03EEEAF62AB8ED3C0087F8D8 /* BubblePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BubblePicker.swift; sourceTree = ""; }; 03EEEAF82ABB985D0087F8D8 /* CommunityModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityModel.swift; sourceTree = ""; }; 03F4DC9C2B193F4C00556C67 /* MatrixLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixLinkView.swift; sourceTree = ""; }; 03F4DC9E2B1A8AD500556C67 /* SignInAndSecuritySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInAndSecuritySettingsView.swift; sourceTree = ""; }; @@ -793,7 +801,7 @@ 6386E0392A0455BC006B3C1D /* String+Contains.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Contains.swift"; sourceTree = ""; }; 6386E03F2A045723006B3C1D /* Website Icon Complex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Website Icon Complex.swift"; sourceTree = ""; }; 63A09B68285F53E9004F0032 /* Error View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Error View.swift"; sourceTree = ""; }; - 63ABCE0D27F894060047A7D0 /* User View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "User View.swift"; sourceTree = ""; }; + 63CE4E722A06F5A100405271 /* Access Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Access Token.swift"; sourceTree = ""; }; 63D24ED82A169A5F005CCA81 /* UIApplication+FirstKeyWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+FirstKeyWindow.swift"; sourceTree = ""; }; 63D24EDD2A169F2A005CCA81 /* Markdown View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Markdown View.swift"; sourceTree = ""; }; 63DF71F02A02999C002AC14E /* App Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App Constants.swift"; sourceTree = ""; }; @@ -2057,6 +2065,7 @@ CD64832B2A38CE4200EE6CA3 /* Settings */, 6317ABCA2A37292700603D76 /* FeedType.swift */, CD6483352A39F20800EE6CA3 /* Post Type.swift */, + 03C905CB2B3C88F700B9082F /* SearchTab.swift */, 6DCE71282A53C26600CFEB5E /* ServerInstanceLocation.swift */, CDDCF6522A677F45003DA3AC /* TabSelection.swift */, CDE9CE4E2A7B0B1B002B97DD /* Haptic.swift */, @@ -2088,7 +2097,7 @@ 03C898022AC04F61005F3403 /* RecentSearchesView.swift */, 03EC92942AC064AE007BBE7E /* SearchHomeView.swift */, 036ED3BB2ABF1058009664BC /* SearchModel.swift */, - 03EEEAF62AB8ED3C0087F8D8 /* SearchTabPicker.swift */, + 03EEEAF62AB8ED3C0087F8D8 /* BubblePicker.swift */, 030D00832AD0842900953B1D /* Results */, ); path = Search; @@ -2106,7 +2115,9 @@ isa = PBXGroup; children = ( 6DE1183B2A4A217400810C7E /* Profile View.swift */, - 63ABCE0D27F894060047A7D0 /* User View.swift */, + 03C905C72B3C70E200B9082F /* UserView.swift */, + 03C905CD2B3C8DC400B9082F /* UserView+Logic.swift */, + 03C905C92B3C834C00B9082F /* UserHeaderView.swift */, 6DEB0FFA2A4F87BF007CAB99 /* User Moderator View.swift */, 039439902A98FA6100463032 /* UserFeedView.swift */, ); @@ -2979,6 +2990,7 @@ 63F0C7A82A0522FC00A18C5D /* Saved Account Tracker.swift in Sources */, E449C5972B35239500E3BCF4 /* InboxReplyView.swift in Sources */, 6372186A2A3A2AAD008C4816 /* GetComment.swift in Sources */, + 03C905CA2B3C834C00B9082F /* UserHeaderView.swift in Sources */, 03F76FA42B2F5F3500E2B54A /* UploadProgressView.swift in Sources */, 03EC92972AC069CE007BBE7E /* SearchResultListView.swift in Sources */, 637218472A3A2AAD008C4816 /* APICommentView.swift in Sources */, @@ -3071,6 +3083,7 @@ 030E86482AC6FD1D000283A6 /* _assignIfNotEqual.swift in Sources */, 6354F30A2A2E20040074C08D /* View+Alert.swift in Sources */, 03EC92992AC0BF8A007BBE7E /* APIClient+Pictrs.swift in Sources */, + 03C905C82B3C70E200B9082F /* UserView.swift in Sources */, 6372186C2A3A2AAD008C4816 /* SaveComment.swift in Sources */, CD4368B62AE23F4700BD8BD1 /* ParentTracker.swift in Sources */, 6DD8677A2A5083A200BEB00F /* Community Sidebar Link.swift in Sources */, @@ -3104,7 +3117,7 @@ 03A2767B2AFE560000C0D66B /* CommunityModel+SwipeActions.swift in Sources */, CDF1EF142A6B6D6E003594B6 /* Feed View Logic.swift in Sources */, 6DFF50432A48DED3001E648D /* Inbox View.swift in Sources */, - 03EEEAF72AB8ED3C0087F8D8 /* SearchTabPicker.swift in Sources */, + 03EEEAF72AB8ED3C0087F8D8 /* BubblePicker.swift in Sources */, CD2053102AC878B50000AA38 /* UpdatedTimestampView.swift in Sources */, CD1446232A5B336900610EF1 /* LicensesView.swift in Sources */, CD4368D02AE245F400BD8BD1 /* MessageTracker.swift in Sources */, @@ -3211,6 +3224,7 @@ 038A16E92A7A9C640087987E /* LayoutWidget.swift in Sources */, CD9A03C82B389F7000C16276 /* EnvironmentValues+FeedType.swift in Sources */, 50811B2E2A92046D006BA3F2 /* URL+Mock.swift in Sources */, + 03C905CC2B3C88F700B9082F /* SearchTab.swift in Sources */, E409E16E2AFEFB8C0026FDC2 /* ImageDetailSheetState.swift in Sources */, CD3FBCE52A4A89B900B2063F /* Mentions Feed View.swift in Sources */, CD391F962A535F5400E213B5 /* ResponseEditorView.swift in Sources */, @@ -3385,6 +3399,7 @@ 03E79F3F2AE3E7100006700D /* SortingSettingsView.swift in Sources */, 039C8DB92B35A81C0096BAAF /* AccountIconStack.swift in Sources */, CDCBD7262A8D69A200387A2C /* Instance Picker View.swift in Sources */, + 03C905CE2B3C8DC400B9082F /* UserView+Logic.swift in Sources */, 6372185B2A3A2AAD008C4816 /* APICommunityView.swift in Sources */, 030E86442AC6F6D5000283A6 /* SearchBar+NavigationView.swift in Sources */, 637218552A3A2AAD008C4816 /* APITagline.swift in Sources */, @@ -3406,7 +3421,6 @@ 637218632A3A2AAD008C4816 /* CreatePost.swift in Sources */, CDE6A80B2A43E9F00062D161 /* CommentSortType.swift in Sources */, 503BA26F2A2C94540052516C /* URL+Identifiable.swift in Sources */, - 63ABCE0E27F894060047A7D0 /* User View.swift in Sources */, CD6483322A38D3A600EE6CA3 /* ScoreCounterView.swift in Sources */, 50CC4A7A2A9CC45D0074C845 /* InstanceMetadata+Mock.swift in Sources */, 6318DE5427FB958800CC2AD6 /* Stickied Tag.swift in Sources */, diff --git a/Mlem/API/Models/Person/APIPersonAggregates.swift b/Mlem/API/Models/Person/APIPersonAggregates.swift index 25e38f0c1..356f2bd1c 100644 --- a/Mlem/API/Models/Person/APIPersonAggregates.swift +++ b/Mlem/API/Models/Person/APIPersonAggregates.swift @@ -12,7 +12,7 @@ struct APIPersonAggregates: Decodable { let id: Int? // TODO: 0.18 Deprecation remove this field let personId: Int let postCount: Int - let postScore: Int? + let postScore: Int? // TODO: 0.18 Deprecation remove this field let commentCount: Int - let commentScore: Int? + let commentScore: Int? // TODO: 0.18 Deprecation remove this field } diff --git a/Mlem/ContentView.swift b/Mlem/ContentView.swift index 44b8932de..564d22fcb 100644 --- a/Mlem/ContentView.swift +++ b/Mlem/ContentView.swift @@ -14,6 +14,7 @@ struct ContentView: View { @Dependency(\.errorHandler) var errorHandler @Dependency(\.personRepository) var personRepository @Dependency(\.hapticManager) var hapticManager + @Dependency(\.siteInformation) var siteInformation @Dependency(\.accountsTracker) var accountsTracker @Environment(\.setAppFlow) private var setFlow @@ -41,6 +42,14 @@ struct ContentView: View { var accessibilityFont: Bool { UIApplication.shared.preferredContentSizeCategory.isAccessibilityCategory } + var myUser: UserModel? { + if let person = siteInformation.myUserInfo?.localUserView.person { + return UserModel(from: person) + } else { + return nil + } + } + var body: some View { FancyTabBar(selection: $tabSelection, navigationSelection: $tabNavigation, dragUpGestureCallback: showAccountSwitcherDragCallback) { Group { @@ -64,20 +73,20 @@ struct ContentView: View { badgeCount: showInboxUnreadBadge ? unreadTracker.total : 0 ) } + } - ProfileView(userID: account.id) - .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) - } + ProfileView(user: myUser) + .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) } SearchRoot() diff --git a/Mlem/Enums/FeedType.swift b/Mlem/Enums/FeedType.swift index ef8e5dc67..f99ccf626 100644 --- a/Mlem/Enums/FeedType.swift +++ b/Mlem/Enums/FeedType.swift @@ -5,16 +5,34 @@ // Created by Jonathan de Jong on 12.06.2023. // -import Foundation +import SwiftUI -enum FeedType: String, Encodable, SettingsOptions { +enum FeedType: String, Encodable, SettingsOptions, AssociatedColor { var id: Self { self } var label: String { + return rawValue + } + + var description: String { + switch self { + case .all: + return "Subscribed communities from all instances" + case .local: + return "Local communities from your server" + case .subscribed: + return "All communities that federate with your server" + } + } + + var color: Color? { switch self { - case .all: return rawValue - case .local: return rawValue - case .subscribed: return rawValue + case .all: + return .blue + case .local: + return .green + case .subscribed: + return .red } } @@ -34,12 +52,20 @@ extension FeedType: AssociatedIcon { var iconNameFill: String { switch self { - case .all: return Icons.federatedFeed + case .all: return Icons.federatedFeedFill case .local: return Icons.localFeedFill case .subscribed: return Icons.subscribedFeedFill } } + var iconNameCircle: String { + switch self { + case .all: return Icons.federatedFeedCircle + case .local: return Icons.localFeedCircle + case .subscribed: return Icons.subscribedFeedCircle + } + } + /// Icon to use in system settings. This should be removed when the "unified symbol handling" is closed var settingsIconName: String { switch self { diff --git a/Mlem/Enums/SearchTab.swift b/Mlem/Enums/SearchTab.swift new file mode 100644 index 000000000..3cb9678d6 --- /dev/null +++ b/Mlem/Enums/SearchTab.swift @@ -0,0 +1,23 @@ +// +// SearchTab.swift +// Mlem +// +// Created by Sjmarf on 27/12/2023. +// + +enum SearchTab: String, CaseIterable, Identifiable { + case topResults, communities, users + + var id: Self { self } + + var label: String { + switch self { + case .topResults: + return "Top Results" + default: + return rawValue.capitalized + } + } + + static var homePageCases: [SearchTab] = [.communities, .users] +} diff --git a/Mlem/Enums/User/UserViewTab.swift b/Mlem/Enums/User/UserViewTab.swift index 09865f548..b17fb9979 100644 --- a/Mlem/Enums/User/UserViewTab.swift +++ b/Mlem/Enums/User/UserViewTab.swift @@ -8,15 +8,11 @@ import Foundation enum UserViewTab: String, CaseIterable, Identifiable { - case overview, comments, posts, saved + case overview, comments, posts, communities, saved var id: Self { self } var label: String { rawValue.capitalized } - - var onlyShowInOwnProfile: Bool { - self == UserViewTab.saved - } } diff --git a/Mlem/Extensions/Date/Date+RelativeTime.swift b/Mlem/Extensions/Date/Date+RelativeTime.swift index e46e07256..3561d0765 100644 --- a/Mlem/Extensions/Date/Date+RelativeTime.swift +++ b/Mlem/Extensions/Date/Date+RelativeTime.swift @@ -9,9 +9,9 @@ import SwiftUI extension Date { // Returns strings like "3 seconds ago" and "10 days ago" - func getRelativeTime(date: Date) -> String { + func getRelativeTime(date: Date, unitsStyle: RelativeDateTimeFormatter.UnitsStyle = .full) -> String { let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full + formatter.unitsStyle = unitsStyle return formatter.localizedString(for: self, relativeTo: date) } diff --git a/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift b/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift index 034ad2c81..4a707b993 100644 --- a/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift +++ b/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift @@ -61,14 +61,15 @@ struct HandleLemmyLinksDisplay: ViewModifier { LazyLoadExpandedPost(post: post) .environmentObject(quickLookState) case let .apiPerson(user): - UserView(userID: user.id) + UserView(user: UserModel(from: user)) + .environmentObject(appState) .environmentObject(quickLookState) - case let .userProfile(user): - UserView(userID: user.userId) + case let .userProfile(user, communityContext): + UserView(user: user, communityContext: communityContext) .environmentObject(appState) .environmentObject(quickLookState) case let .postLinkWithContext(post): - ExpandedPost(post: post.post, scrollTarget: post.scrollTarget) + ExpandedPost(post: post.post, community: post.community, scrollTarget: post.scrollTarget) .environmentObject(post.postTracker) .environmentObject(appState) .environmentObject(quickLookState) diff --git a/Mlem/Icons.swift b/Mlem/Icons.swift index bab63791a..83e93d839 100644 --- a/Mlem/Icons.swift +++ b/Mlem/Icons.swift @@ -63,12 +63,18 @@ struct Icons { static let largePostFill: String = "text.below.photo.fill" // feeds - static let federatedFeed: String = "circle.hexagongrid.circle" - static let federatedFeedFill: String = "circle.hexagongrid.circle.fill" - static let localFeed: String = "house.circle" - static let localFeedFill: String = "house.circle.fill" - static let subscribedFeed: String = "newspaper.circle" - static let subscribedFeedFill: String = "newspaper.circle.fill" + static let federatedFeed: String = "circle.hexagongrid" + static let federatedFeedFill: String = "circle.hexagongrid.fill" + static let federatedFeedCircle: String = "circle.hexagongrid.circle.fill" + static let localFeed: String = "house" + static let localFeedFill: String = "house.fill" + static let localFeedCircle: String = "house.circle.fill" + static let subscribedFeed: String = "newspaper" + static let subscribedFeedFill: String = "newspaper.fill" + static let subscribedFeedCircle: String = "newspaper.circle.fill" + static let savedFeed: String = "bookmark" + static let savedFeedFill: String = "bookmark.fill" + static let savedFeedCircle: String = "bookmark.circle.fill" // sort types static let activeSort: String = "popcorn" @@ -136,6 +142,7 @@ struct Icons { static let favoriteFill: String = "star.fill" static let personFill: String = "person.fill" static let close: String = "multiply" + static let cakeDay: String = "birthday.cake" // end of feed static let endOfFeedHobbit: String = "figure.climbing" @@ -157,6 +164,7 @@ struct Icons { static let attachment: String = "paperclip" static let edit: String = "pencil" static let delete: String = "trash" + static let copy: String = "doc.on.doc" // settings static let upvoteOnSave: String = "arrow.up.heart" diff --git a/Mlem/MlemApp.swift b/Mlem/MlemApp.swift index fce3d4a03..921b71e03 100644 --- a/Mlem/MlemApp.swift +++ b/Mlem/MlemApp.swift @@ -75,32 +75,32 @@ struct MlemApp: App { guard accountsTracker.savedAccounts.first != nil else { return } // Subscribed Feed - let homeIcon = UIApplicationShortcutIcon(systemImageName: "house") + let subscribedIcon = UIApplicationShortcutIcon(systemImageName: Icons.subscribedFeed) let subscribedFeedItem = UIApplicationShortcutItem( type: FeedType.subscribed.rawValue, localizedTitle: "Subscribed", localizedSubtitle: nil, - icon: homeIcon, + icon: subscribedIcon, userInfo: nil ) // Local Feed - let officeIcon = UIApplicationShortcutIcon(systemImageName: "building.2") + let localIcon = UIApplicationShortcutIcon(systemImageName: Icons.localFeed) let localFeedItem = UIApplicationShortcutItem( type: FeedType.local.rawValue, localizedTitle: "Local", localizedSubtitle: nil, - icon: officeIcon, + icon: localIcon, userInfo: nil ) // All Feed - let cloudIcon = UIApplicationShortcutIcon(systemImageName: "cloud") + let allIcon = UIApplicationShortcutIcon(systemImageName: Icons.federatedFeed) let allFeedItem = UIApplicationShortcutItem( type: FeedType.all.rawValue, localizedTitle: "All", localizedSubtitle: nil, - icon: cloudIcon, + icon: allIcon, userInfo: nil ) diff --git a/Mlem/Models/Content/User/UserModel+MenuFunctions.swift b/Mlem/Models/Content/User/UserModel+MenuFunctions.swift index 34b6ed3ec..315bcecdc 100644 --- a/Mlem/Models/Content/User/UserModel+MenuFunctions.swift +++ b/Mlem/Models/Content/User/UserModel+MenuFunctions.swift @@ -25,8 +25,19 @@ extension UserModel { func menuFunctions(_ callback: @escaping (_ item: Self) -> Void = { _ in }) -> [MenuFunction] { var functions: [MenuFunction] = .init() + functions.append( + .standardMenuFunction( + text: "Copy Username", + imageName: Icons.copy, + destructiveActionPrompt: nil, + enabled: true, + callback: copyFullyQualifiedUsername + ) + ) functions.append(.shareMenuFunction(url: profileUrl)) - functions.append(blockMenuFunction(callback)) + if siteInformation.myUserInfo?.localUserView.person.id != userId { + functions.append(blockMenuFunction(callback)) + } return functions } } diff --git a/Mlem/Models/Content/User/UserModel.swift b/Mlem/Models/Content/User/UserModel.swift index 2630b86b5..12dccc1c2 100644 --- a/Mlem/Models/Content/User/UserModel.swift +++ b/Mlem/Models/Content/User/UserModel.swift @@ -12,7 +12,9 @@ import SwiftUI struct UserModel { @Dependency(\.personRepository) var personRepository @Dependency(\.hapticManager) var hapticManager + @Dependency(\.siteInformation) var siteInformation @Dependency(\.errorHandler) var errorHandler + @Dependency(\.notifier) var notifier @available(*, deprecated, message: "Use attributes of the UserModel directly instead.") var person: APIPerson @@ -36,8 +38,8 @@ struct UserModel { let local: Bool let deleted: Bool let isBot: Bool - let isAdmin: Bool - + var blocked: Bool + // Dates let creationDate: Date let updatedDate: Date? @@ -47,11 +49,13 @@ struct UserModel { let profileUrl: URL let sharedInboxUrl: URL? - // These values are nil if the UserModel was created from an APIPerson and not an APIPersonView + // From APIPersonView + var isAdmin: Bool? var postCount: Int? var commentCount: Int? - var blocked: Bool + // From GetPersonDetailsResponse + var moderatedCommunities: [CommunityModel]? static let developerNames = [ "https://lemmy.tespia.org/u/navi", @@ -62,12 +66,26 @@ struct UserModel { "https://lemmy.ml/u/sjmarf" ] + /// Creates a UserModel from an GetPersonDetailsResponse + /// - Parameter response: GetPersonDetailsResponse to create a UserModel representation of + init(from response: GetPersonDetailsResponse) { + self.init(from: response.personView) + self.moderatedCommunities = response.moderates.map { CommunityModel(from: $0.community) } + } + /// Creates a UserModel from an APIPersonView /// - Parameter apiPersonView: APIPersonView to create a UserModel representation of init(from personView: APIPersonView) { self.init(from: personView.person) + self.postCount = personView.counts.postCount self.commentCount = personView.counts.commentCount + + // TODO: 0.18 Deprecation + @Dependency(\.siteInformation) var siteInformation + if (siteInformation.version ?? .infinity) > .init("0.19.0") { + self.isAdmin = personView.isAdmin + } } /// Creates a UserModel from an APIPerson. Note that using this initialiser nullifies count values, since @@ -88,7 +106,8 @@ struct UserModel { self.local = person.local self.deleted = person.deleted self.isBot = person.botAccount - self.isAdmin = person.admin ?? false // is nil on Beehaw + + self.isAdmin = person.admin self.creationDate = person.published self.updatedDate = person.updated @@ -115,7 +134,7 @@ struct UserModel { if let post = postContext, post.creatorId == self.userId { ret.append(.op) } - if isAdmin { + if isAdmin ?? false { ret.append(.admin) } if UserModel.developerNames.contains(profileUrl.absoluteString) { @@ -153,6 +172,31 @@ struct UserModel { errorHandler.handle(error) } } + + static func mock() -> UserModel { + return self.init(from: APIPerson.mock()) + } + + var fullyQualifiedUsername: String? { + if let host = self.profileUrl.host() { + return "@\(name)@\(host)" + } + return nil + } + + func copyFullyQualifiedUsername() { + let pasteboard = UIPasteboard.general + if let fullyQualifiedUsername { + pasteboard.string = fullyQualifiedUsername + Task { + await notifier.add(.success("Username Copied")) + } + } else { + Task { + await notifier.add(.failure("Failed to copy")) + } + } + } } extension UserModel: Identifiable { @@ -168,5 +212,7 @@ extension UserModel: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(uid) hasher.combine(blocked) + hasher.combine(postCount) + hasher.combine(commentCount) } } diff --git a/Mlem/Models/Navigation Contexts/Post Link.swift b/Mlem/Models/Navigation Contexts/Post Link.swift index 0cca31972..50494b866 100644 --- a/Mlem/Models/Navigation Contexts/Post Link.swift +++ b/Mlem/Models/Navigation Contexts/Post Link.swift @@ -20,6 +20,7 @@ struct PostLinkWithContext: Equatable, Identifiable, Hashable { var id: Int { post.postId } let post: PostModel + var community: CommunityModel? let postTracker: PostTracker var scrollTarget: Int? } diff --git a/Mlem/Models/Trackers/SiteInformationTracker.swift b/Mlem/Models/Trackers/SiteInformationTracker.swift index 33b07bfd4..0fd58be25 100644 --- a/Mlem/Models/Trackers/SiteInformationTracker.swift +++ b/Mlem/Models/Trackers/SiteInformationTracker.swift @@ -23,12 +23,15 @@ class SiteInformationTracker: ObservableObject { version = account.siteVersion Task { do { + let response = try await apiClient.loadSiteInformation() enableDownvotes = response.siteView.localSite.enableDownvotes version = SiteVersion(response.version) if version != account.siteVersion { let avatarUrl = response.myUser?.localUserView.person.avatarUrl - accountsTracker.update(with: .init(from: account, avatarUrl: avatarUrl, siteVersion: version)) + DispatchQueue.main.async { + self.accountsTracker.update(with: .init(from: account, avatarUrl: avatarUrl, siteVersion: self.version)) + } } myUserInfo = response.myUser allLanguages = response.allLanguages diff --git a/Mlem/Navigation/Routes/AppRoutes.swift b/Mlem/Navigation/Routes/AppRoutes.swift index ca983937f..5c9f3411e 100644 --- a/Mlem/Navigation/Routes/AppRoutes.swift +++ b/Mlem/Navigation/Routes/AppRoutes.swift @@ -10,10 +10,11 @@ import Foundation /// Possible routes for navigation links in `Mlem.app`. /// /// For simple (i.e. linear) navigation flows, you may wish to define a separate set of routes. For example, see `OnboardingRoutes`. +/// enum AppRoute: Routable { case communityLinkWithContext(CommunityLinkWithContext) case communitySidebarLinkWithContext(CommunitySidebarLinkWithContext) - + case apiPostView(APIPostView) case apiPost(APIPost) @@ -21,7 +22,7 @@ enum AppRoute: Routable { @available(*, deprecated, message: "Use .userProfile instead.") case apiPerson(APIPerson) - case userProfile(UserModel) + case userProfile(UserModel, communityContext: CommunityModel? = nil) case postLinkWithContext(PostLinkWithContext) case lazyLoadPostLinkWithContext(LazyLoadPostLinkWithContext) diff --git a/Mlem/Views/Shared/Links/User/UserLinkView.swift b/Mlem/Views/Shared/Links/User/UserLinkView.swift index aff3dc23e..7a22c22c5 100644 --- a/Mlem/Views/Shared/Links/User/UserLinkView.swift +++ b/Mlem/Views/Shared/Links/User/UserLinkView.swift @@ -16,6 +16,7 @@ struct UserLinkView: View { // to pick the correct flair var postContext: APIPost? var commentContext: APIComment? + var communityContext: CommunityModel? @available(*, deprecated, message: "Provide a UserModel rather than an APIPerson.") init( @@ -23,14 +24,16 @@ struct UserLinkView: View { serverInstanceLocation: ServerInstanceLocation, overrideShowAvatar: Bool? = nil, postContext: APIPost? = nil, - commentContext: APIComment? = nil + commentContext: APIComment? = nil, + communityContext: CommunityModel? = nil ) { self.init( user: UserModel(from: person), serverInstanceLocation: serverInstanceLocation, overrideShowAvatar: overrideShowAvatar, postContext: postContext, - commentContext: commentContext + commentContext: commentContext, + communityContext: communityContext ) } @@ -39,23 +42,27 @@ struct UserLinkView: View { serverInstanceLocation: ServerInstanceLocation, overrideShowAvatar: Bool? = nil, postContext: APIPost? = nil, - commentContext: APIComment? = nil + commentContext: APIComment? = nil, + communityContext: CommunityModel? = nil + ) { self.user = user self.serverInstanceLocation = serverInstanceLocation self.overrideShowAvatar = overrideShowAvatar self.postContext = postContext self.commentContext = commentContext + self.communityContext = communityContext } var body: some View { - NavigationLink(.userProfile(user)) { + NavigationLink(.userProfile(user, communityContext: communityContext)) { UserLabelView( user: user, serverInstanceLocation: serverInstanceLocation, overrideShowAvatar: overrideShowAvatar, postContext: postContext, - commentContext: commentContext + commentContext: commentContext, + communityContext: communityContext ) } .buttonStyle(.plain) diff --git a/Mlem/Views/Shared/Loading View.swift b/Mlem/Views/Shared/Loading View.swift index 814218f5b..090b7a727 100644 --- a/Mlem/Views/Shared/Loading View.swift +++ b/Mlem/Views/Shared/Loading View.swift @@ -9,7 +9,8 @@ import SwiftUI struct LoadingView: View { enum PossibleThingsToLoad { - case posts, image, comments, inbox, replies, mentions, messages, communityDetails, search, instances, instanceDetails + case posts, image, comments, inbox, replies, mentions, messages, + communityDetails, search, instances, instanceDetails, content, profile } let whatIsLoading: PossibleThingsToLoad @@ -23,6 +24,10 @@ struct LoadingView: View { switch whatIsLoading { case .posts: Text("Loading posts") + case .content: + Text("Loading content") + case .profile: + Text("Loading profile") case .image: Text("Loading image") case .comments: diff --git a/Mlem/Views/Shared/Markdown View.swift b/Mlem/Views/Shared/Markdown View.swift index 14d7108b8..e860dde80 100644 --- a/Mlem/Views/Shared/Markdown View.swift +++ b/Mlem/Views/Shared/Markdown View.swift @@ -226,14 +226,22 @@ struct MarkdownView: View { private let isNsfw: Bool private let replaceImagesWithEmoji: Bool private let isInline: Bool + private let alignment: TextAlignment - init(text: String, isNsfw: Bool, replaceImagesWithEmoji: Bool = false, isInline: Bool = false) { + init( + text: String, + isNsfw: Bool, + replaceImagesWithEmoji: Bool = false, + isInline: Bool = false, + alignment: TextAlignment = .leading + ) { _text = isInline ? .init(wrappedValue: MarkdownView.prepareInlineMarkdown(text: text)) : .init(wrappedValue: text) self.isNsfw = isNsfw self.replaceImagesWithEmoji = replaceImagesWithEmoji self.isInline = isInline + self.alignment = alignment } var body: some View { @@ -312,7 +320,8 @@ struct MarkdownView: View { func renderAsMarkdown(text: String, theme: Theme = .mlem) -> some View { Markdown(text) - .frame(maxWidth: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: alignment == .center ? .top : .topLeading) + .multilineTextAlignment(alignment) .markdownTheme(theme) } } diff --git a/Mlem/Views/Shared/Posts/Expanded Post.swift b/Mlem/Views/Shared/Posts/Expanded Post.swift index 32cb69ef3..29feb5489 100644 --- a/Mlem/Views/Shared/Posts/Expanded Post.swift +++ b/Mlem/Views/Shared/Posts/Expanded Post.swift @@ -56,6 +56,7 @@ struct ExpandedPost: View { @StateObject var commentTracker: CommentTracker = .init() @EnvironmentObject var postTracker: PostTracker @State var post: PostModel + var community: CommunityModel? @State var commentErrorDetails: ErrorDetails? @@ -237,7 +238,8 @@ struct ExpandedPost: View { UserLinkView( user: post.creator, - serverInstanceLocation: userServerInstanceLocation + serverInstanceLocation: userServerInstanceLocation, + communityContext: community ) } .padding(.top, AppConstants.postAndCommentSpacing) diff --git a/Mlem/Views/Shared/Posts/Feed Post.swift b/Mlem/Views/Shared/Posts/Feed Post.swift index 3ed8b4862..b2509fcfa 100644 --- a/Mlem/Views/Shared/Posts/Feed Post.swift +++ b/Mlem/Views/Shared/Posts/Feed Post.swift @@ -56,17 +56,20 @@ struct FeedPost: View { // MARK: Parameters let post: PostModel + let community: CommunityModel? let showPostCreator: Bool let showCommunity: Bool let enableSwipeActions: Bool init( post: PostModel, + community: CommunityModel? = nil, showPostCreator: Bool = true, showCommunity: Bool = true, enableSwipeActions: Bool = true ) { self.post = post + self.community = community self.showPostCreator = showPostCreator self.showCommunity = showCommunity self.enableSwipeActions = enableSwipeActions @@ -183,7 +186,8 @@ struct FeedPost: View { if showPostCreator { UserLinkView( user: post.creator, - serverInstanceLocation: userServerInstanceLocation + serverInstanceLocation: userServerInstanceLocation, + communityContext: community ) } } diff --git a/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift b/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift index 150f716ee..424688f2e 100644 --- a/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift +++ b/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift @@ -33,14 +33,16 @@ struct CompactPost: View { // arguments let post: PostModel + let community: CommunityModel? let showCommunity: Bool // true to show community name, false to show username let menuFunctions: [MenuFunction] // computed var showReadCheck: Bool { post.read && diffWithoutColor && readMarkStyle == .check } - init(post: PostModel, showCommunity: Bool, menuFunctions: [MenuFunction]) { + init(post: PostModel, community: CommunityModel? = nil, showCommunity: Bool, menuFunctions: [MenuFunction]) { self.post = post + self.community = community self.showCommunity = showCommunity self.menuFunctions = menuFunctions } @@ -59,7 +61,8 @@ struct CompactPost: View { } else { UserLinkView( user: post.creator, - serverInstanceLocation: .trailing + serverInstanceLocation: .trailing, + communityContext: community ) } } diff --git a/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift b/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift index 7e82b6ab2..d3ac1ebd8 100644 --- a/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift +++ b/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift @@ -22,18 +22,6 @@ struct HeadlinePost: View { // arguments let post: PostModel - // computed - var usernameColor: Color { - if post.creator.isAdmin { - return .red - } - if post.creator.isBot { - return .indigo - } - - return .secondary - } - var body: some View { VStack(alignment: .leading, spacing: AppConstants.postAndCommentSpacing) { HStack(alignment: .top, spacing: spacing) { diff --git a/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift b/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift index 094958579..2a6d7b979 100644 --- a/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift +++ b/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift @@ -34,28 +34,11 @@ struct CommunityListView: View { ScrollViewReader { scrollProxy in HStack { List(selection: $selectedCommunity) { - HomepageFeedRowView( - feedType: .subscribed, - iconName: Icons.subscribedFeedFill, - iconColor: .red, - description: "Subscribed communities from all servers", - navigationContext: .sidebar - ) - .id("top") // For "scroll to top" sidebar item - HomepageFeedRowView( - feedType: .local, - iconName: Icons.localFeedFill, - iconColor: .green, - description: "Local communities from your server", - navigationContext: .sidebar - ) - HomepageFeedRowView( - feedType: .all, - iconName: Icons.federatedFeedFill, - iconColor: .blue, - description: "All communities that federate with your server", - navigationContext: .sidebar - ) + HomepageFeedRowView(.subscribed) + .padding(.top, 5) + .id("top") // For "scroll to top" sidebar item + HomepageFeedRowView(.local) + HomepageFeedRowView(.all) ForEach(model.visibleSections) { section in Section(header: headerView(for: section)) { diff --git a/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift b/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift index 834f1aee8..1055fead3 100644 --- a/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift +++ b/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift @@ -132,19 +132,19 @@ struct CommuntiyFeedRowView: View { struct HomepageFeedRowView: View { let feedType: FeedType - let iconName: String - let iconColor: Color - let description: String - let navigationContext: NavigationContext + + init(_ feedType: FeedType) { + self.feedType = feedType + } var body: some View { NavigationLink(value: pathValue) { HStack { - Image(systemName: iconName).resizable() - .frame(width: 36, height: 36).foregroundColor(iconColor) + Image(systemName: feedType.iconNameCircle).resizable() + .frame(width: 36, height: 36).foregroundColor(feedType.color) VStack(alignment: .leading) { Text("\(feedType.label) Communities") - Text(description).font(.caption).foregroundColor(.gray) + Text(feedType.description).font(.caption).foregroundColor(.gray) } } .padding(.bottom, 1) @@ -153,11 +153,6 @@ struct HomepageFeedRowView: View { } private var pathValue: AnyHashable { - if navigationContext == .sidebar { - return CommunityLinkWithContext(community: nil, feedType: feedType) - } else { - // Do not use enum route path in sidebar: It doesn't work, and I have no idea why =/ [2023.09] - return AppRoute.communityLinkWithContext(.init(community: nil, feedType: feedType)) - } + return CommunityLinkWithContext(community: nil, feedType: feedType) } } diff --git a/Mlem/Views/Tabs/Feeds/Feed View.swift b/Mlem/Views/Tabs/Feeds/Feed View.swift index 6acafb912..c2ff69027 100644 --- a/Mlem/Views/Tabs/Feeds/Feed View.swift +++ b/Mlem/Views/Tabs/Feeds/Feed View.swift @@ -250,9 +250,10 @@ struct FeedView: View { private func feedPost(for post: PostModel) -> some View { VStack(spacing: 0) { // TODO: reenable nav - NavigationLink(.postLinkWithContext(.init(post: post, postTracker: postTracker))) { + NavigationLink(.postLinkWithContext(.init(post: post, community: community, postTracker: postTracker))) { FeedPost( post: post, + community: community, showPostCreator: shouldShowPostCreator, showCommunity: community == nil ) diff --git a/Mlem/Views/Tabs/Profile/Profile View.swift b/Mlem/Views/Tabs/Profile/Profile View.swift index 048101249..2cede85e9 100644 --- a/Mlem/Views/Tabs/Profile/Profile View.swift +++ b/Mlem/Views/Tabs/Profile/Profile View.swift @@ -12,7 +12,7 @@ struct ProfileView: View { // appstorage @AppStorage("shouldShowUserHeaders") var shouldShowUserHeaders: Bool = true - let userID: Int + let user: UserModel? @StateObject private var profileTabNavigation: AnyNavigationPath = .init() @StateObject private var navigation: Navigation = .init() @@ -20,10 +20,15 @@ struct ProfileView: View { var body: some View { ScrollViewReader { proxy in NavigationStack(path: $profileTabNavigation.path) { - UserView(userID: userID) - .handleLemmyViews() - .environmentObject(profileTabNavigation) - .tabBarNavigationEnabled(.profile, navigation) + if let user { + UserView(user: user) + .handleLemmyViews() + .environmentObject(profileTabNavigation) + .tabBarNavigationEnabled(.profile, navigation) + } else { + LoadingView(whatIsLoading: .profile) + .fancyTabScrollCompatible() + } } .handleLemmyLinkResolution(navigationPath: .constant(profileTabNavigation)) .environment(\.navigationPathWithRoutes, $profileTabNavigation.path) diff --git a/Mlem/Views/Tabs/Profile/User View.swift b/Mlem/Views/Tabs/Profile/User View.swift deleted file mode 100644 index 4188c38a9..000000000 --- a/Mlem/Views/Tabs/Profile/User View.swift +++ /dev/null @@ -1,431 +0,0 @@ -// -// User View.swift -// Mlem -// -// Created by David Bureš on 02.04.2022. -// - -import Dependencies -import SwiftUI - -// swiftlint:disable file_length - -/// View for showing user profiles -/// Accepts the following parameters: -/// - **userID**: Non-optional ID of the user -struct UserView: View { - @Dependency(\.apiClient) var apiClient - @Dependency(\.errorHandler) var errorHandler - @Dependency(\.notifier) var notifier - @Dependency(\.personRepository) var personRepository - @Dependency(\.accountsTracker) var accountsTracker - - // appstorage - @AppStorage("shouldShowUserHeaders") var shouldShowUserHeaders: Bool = true - let internetSpeed: InternetSpeed - - // environment - @Environment(\.navigationPathWithRoutes) private var navigationPath - @Environment(\.scrollViewProxy) private var scrollViewProxy - @EnvironmentObject var appState: AppState - - // parameters - @State var userID: Int - @State var userDetails: APIPersonView? - - @StateObject private var privatePostTracker: PostTracker - @StateObject private var privateCommentTracker: CommentTracker = .init() - @State private var avatarSubtext: String = "" - @State private var showingCakeDay = false - @State private var moderatedCommunities: [APICommunityModeratorView] = [] - - @State private var selectionSection = UserViewTab.overview - @State private var errorDetails: ErrorDetails? - - @Namespace var scrollToTop - @State private var scrollToTopAppeared = false - - init(userID: Int, userDetails: APIPersonView? = nil) { - @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast - @AppStorage("upvoteOnSave") var upvoteOnSave = false - - self.internetSpeed = internetSpeed - - self._userID = State(initialValue: userID) - self._userDetails = State(initialValue: userDetails) - - self._privatePostTracker = StateObject(wrappedValue: .init( - shouldPerformMergeSorting: false, - internetSpeed: internetSpeed, - upvoteOnSave: upvoteOnSave - )) - } - - // account switching - @State private var isPresentingAccountSwitcher: Bool = false - - var body: some View { - if let errorDetails { - ErrorView(errorDetails) - .fancyTabScrollCompatible() - .hoistNavigation() - } else { - contentView - .hoistNavigation { - if navigationPath.isEmpty { - withAnimation { - scrollViewProxy?.scrollTo(scrollToTop) - } - return true - } else { - if scrollToTopAppeared { - return false - } else { - withAnimation { - scrollViewProxy?.scrollTo(scrollToTop) - } - return true - } - } - } - .sheet(isPresented: $isPresentingAccountSwitcher) { - Form { - AccountListView() - } - } - } - } - - @ViewBuilder - private var contentView: some View { - if let userDetails { - view(for: userDetails) - } else { - progressView - } - } - - @ViewBuilder - private var moderatorButton: some View { - if let user = userDetails, !moderatedCommunities.isEmpty { - NavigationLink(.userModeratorLink(.init(user: user, moderatedCommunities: moderatedCommunities))) { - Image(systemName: Icons.moderation) - } - } - } - - @ViewBuilder - private var accountSwitcher: some View { - if isShowingOwnProfile() && accountsTracker.savedAccounts.count > 1 { - Button { - isPresentingAccountSwitcher = true - } label: { - Image(systemName: Icons.switchUser) - } - } - } - - private func header(for userDetails: APIPersonView) -> some View { - CommunitySidebarHeader( - title: userDetails.person.displayName ?? userDetails.person.name, - subtitle: "@\(userDetails.person.name)@\(userDetails.person.actorId.host()!)", - avatarSubtext: $avatarSubtext, - avatarSubtextClicked: toggleCakeDayVisible, - bannerURL: shouldShowUserHeaders ? userDetails.person.bannerUrl : nil, - avatarUrl: userDetails.person.avatarUrl, - label1: "\(userDetails.counts.commentCount) Comments", - label2: "\(userDetails.counts.postCount) Posts", - avatarType: .user - ) - } - - private func view(for userDetails: APIPersonView) -> some View { - ScrollView { - ScrollToView(appeared: $scrollToTopAppeared) - .id(scrollToTop) - - header(for: userDetails) - - if let bio = userDetails.person.bio { - MarkdownView(text: bio, isNsfw: false).padding() - } - - Picker(selection: $selectionSection, label: Text("Profile Section")) { - ForEach(UserViewTab.allCases, id: \.id) { tab in - // Skip tabs that are meant for only our profile - if tab.onlyShowInOwnProfile { - if isShowingOwnProfile() { - Text(tab.label).tag(tab.rawValue) - } - } else { - Text(tab.label).tag(tab.rawValue) - } - } - } - .pickerStyle(.segmented) - .padding(.horizontal) - - UserFeedView( - userID: userID, - privatePostTracker: privatePostTracker, - privateCommentTracker: privateCommentTracker, - selectedTab: $selectionSection - ) - } - .fancyTabScrollCompatible() - .environmentObject(privatePostTracker) - .environmentObject(privateCommentTracker) - .navigationTitle(userDetails.person.displayName ?? userDetails.person.name) - .navigationBarTitleDisplayMode(.inline) - .navigationBarColor() - .headerProminence(.standard) - .refreshable { - await tryReloadUser() - }.toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - accountSwitcher - moderatorButton - } - } - } - - private func updateAvatarSubtext() { - if let user = userDetails { - if showingCakeDay { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "ddMMYY", options: 0, locale: Locale.current) - - avatarSubtext = "Joined \(dateFormatter.string(from: user.person.published))" - } else { - avatarSubtext = "Joined \(user.person.published.getRelativeTime(date: Date.now))" - } - } else { - avatarSubtext = "" - } - } - - private func toggleCakeDayVisible() { - showingCakeDay = !showingCakeDay - updateAvatarSubtext() - } - - private func isShowingOwnProfile() -> Bool { - appState.isCurrentAccountId(userID) - } - - @MainActor - private var progressView: some View { - ProgressView { - if isShowingOwnProfile() { - Text("Loading your profile…") - } else { - Text("Loading user profile…") - } - } - .task(priority: .userInitiated) { - await tryReloadUser() - } - } - - private func tryReloadUser() async { - do { - let authoredContent = try await personRepository.loadUserDetails(for: userID, limit: internetSpeed.pageSize) - var savedContentData: GetPersonDetailsResponse? - if isShowingOwnProfile() { - savedContentData = try await personRepository.loadUserDetails( - for: userID, - limit: internetSpeed.pageSize, - savedOnly: true - ) - } - - // accumulate comments and posts so we don't update state more than we need to - var newComments = authoredContent.comments - .sorted(by: { $0.comment.published > $1.comment.published }) - .map { HierarchicalComment(comment: $0, children: [], parentCollapsed: false, collapsed: false) } - - var newPosts = authoredContent.posts.map { PostModel(from: $0) } - - // add saved content, if present - if let savedContent = savedContentData { - newComments.append(contentsOf: - savedContent.comments - .sorted(by: { $0.comment.published > $1.comment.published }) - .map { HierarchicalComment(comment: $0, children: [], parentCollapsed: false, collapsed: false) }) - - newPosts.append(contentsOf: savedContent.posts.map { PostModel(from: $0) }) - } - - privateCommentTracker.comments = newComments - privatePostTracker.reset(with: newPosts) - - userDetails = authoredContent.personView - moderatedCommunities = authoredContent.moderates - updateAvatarSubtext() - - errorDetails = nil - } catch { - if userDetails == nil { - errorDetails = ErrorDetails(error: error, refresh: { - await tryReloadUser() - return userDetails != nil - }) - } else { - errorHandler.handle( - .init( - title: "Couldn't load user info", - message: "There was an error while loading user information.\nTry again later.", - underlyingError: error - ) - ) - } - } - } -} - -// TODO: darknavi - Move these to a common area for reuse -struct UserViewPreview: PreviewProvider { - static let previewAccount = SavedAccount( - id: 0, - instanceLink: URL(string: "lemmy.com")!, - accessToken: "abcdefg", - username: "Test Account" - ) - - // Only Admin and Bot work right now - // Because the rest require post/comment context - enum PreviewUserType: String, CaseIterable { - case normal - case mod - case op - case bot - case admin - case dev = "developer" - } - - static func generatePreviewUser( - name: String, - displayName: String, - userType: PreviewUserType - ) -> APIPerson { - .mock( - id: name.hashValue, - name: name, - displayName: displayName, - avatar: "https://lemmy.ml/pictrs/image/df86c06d-341c-4e79-9c80-d7c7eb64967a.jpeg?format=webp", - published: Date.now.advanced(by: -10000), - actorId: URL(string: "https://google.com")!, - bio: "Just here for the good vibes!", - banner: "https://i.imgur.com/wcayaCB.jpeg", - admin: userType == .admin, - botAccount: userType == .bot - ) - } - - static func generatePreviewComment(creator: APIPerson, isMod: Bool) -> APIComment { - APIComment( - id: 0, - creatorId: creator.id, - postId: 0, - content: "", - removed: false, - deleted: false, - published: Date.now, - updated: nil, - apId: "foo.bar", - local: false, - path: "foo", - distinguished: isMod, - languageId: 0 - ) - } - - static func generateFakeCommunity(id: Int, namePrefix: String) -> APICommunity { - .mock( - id: id, - name: "\(namePrefix) Fake Community \(id)", - title: "\(namePrefix) Fake Community \(id) Title", - description: "This is a fake community (#\(id))", - published: Date.now, - actorId: URL(string: "https://lemmy.google.com/c/\(id)")! - ) - } - - static func generatePreviewPost(creator: APIPerson) -> PostModel { - let community = generateFakeCommunity(id: 123, namePrefix: "Test") - let post: APIPost = .mock( - name: "Test Post Title", - body: "This is a test post body", - creatorId: creator.id, - embedDescription: "Embeedded Description", - embedTitle: "Embedded Title", - published: Date.now - ) - - let postVotes = APIPostAggregates( - id: 123, - postId: post.id, - comments: 0, - score: 10, - upvotes: 15, - downvotes: 5, - published: Date.now, - newestCommentTime: Date.now, - newestCommentTimeNecro: Date.now, - featuredCommunity: false, - featuredLocal: false - ) - - return PostModel(from: APIPostView( - post: post, - creator: creator, - community: community, - creatorBannedFromCommunity: false, - creatorIsModerator: false, - creatorIsAdmin: false, - counts: postVotes, - subscribed: .notSubscribed, - saved: false, - read: false, - creatorBlocked: false, - unreadComments: 0 - )) - } - - static func generateUserLinkView(name: String, userType: PreviewUserType) -> UserLinkView { - let previewUser = generatePreviewUser(name: name, displayName: name, userType: userType) - - var postContext: PostModel? - var commentContext: APIComment? - - if userType == .mod { - commentContext = generatePreviewComment(creator: previewUser, isMod: true) - } - - if userType == .op { - commentContext = generatePreviewComment(creator: previewUser, isMod: false) - postContext = generatePreviewPost(creator: previewUser) - } - - return UserLinkView( - user: UserModel(from: previewUser), - serverInstanceLocation: .bottom, - overrideShowAvatar: true, - postContext: postContext?.post, - commentContext: commentContext - ) - } - - static var previews: some View { - UserView( - userID: 123, - userDetails: APIPersonView( - person: generatePreviewUser(name: "actualUsername", displayName: "PreferredUsername", userType: .normal), - counts: APIPersonAggregates(id: 123, personId: 123, postCount: 123, postScore: 567, commentCount: 14, commentScore: 974), - isAdmin: false - ) - ).environmentObject(AppState()) - } -} - -// swiftlint:enable file_length diff --git a/Mlem/Views/Tabs/Profile/UserFeedView.swift b/Mlem/Views/Tabs/Profile/UserFeedView.swift index 424dc4cfb..ffbaeddd1 100644 --- a/Mlem/Views/Tabs/Profile/UserFeedView.swift +++ b/Mlem/Views/Tabs/Profile/UserFeedView.swift @@ -6,11 +6,16 @@ // import SwiftUI +import Dependencies struct UserFeedView: View { - var userID: Int - @StateObject var privatePostTracker: PostTracker - @StateObject var privateCommentTracker: CommentTracker + @Dependency(\.siteInformation) var siteInformation + @EnvironmentObject var editorTracker: EditorTracker + + var user: UserModel + @ObservedObject var privatePostTracker: PostTracker + @ObservedObject var privateCommentTracker: CommentTracker + @ObservedObject var communityTracker: ContentTracker @Binding var selectedTab: UserViewTab @@ -27,38 +32,49 @@ struct UserFeedView: View { let hashValue: Int } + var isOwnProfile: Bool { + return siteInformation.myUserInfo?.localUserView.person.id == user.userId + } + var body: some View { - let feed = generateFeed() - .sorted(by: { - $0.published > $1.published - }) - if feed.isEmpty { - emptyFeed - } else { - if selectedTab == .posts { - VStack(spacing: 0) { - content(feed) + LazyVStack(spacing: 0) { + switch selectedTab { + case .communities: + Label( + "\(user.displayName) moderates \(communityTracker.items.count) communities.", + systemImage: Icons.moderationFill + ) + .foregroundStyle(.secondary) + .font(.footnote) + .padding(.vertical, 4) + Divider() + ForEach(communityTracker.items, id: \.wrappedValue.uid) { model in + if let community = model.wrappedValue as? CommunityModel { + CommunityResultView(community: community, showTypeLabel: false) + } + + Divider() } - } else { - LazyVStack(spacing: 0) { - content(feed) + .environmentObject(communityTracker) + default: + let feedItems = generateFeed() + if feedItems.isEmpty { + emptyFeed + } else { + ForEach(feedItems, id: \.uid) { feedItem in + if let post = feedItem.post { + postEntry(for: post) + } + if let comment = feedItem.comment { + commentEntry(for: comment) + } + } } } } } - func content(_ feed: [FeedItem]) -> some View { - ForEach(feed, id: \.uid) { feedItem in - if let post = feedItem.post { - postEntry(for: post) - } - if let comment = feedItem.comment { - commentEntry(for: comment) - } - } - } - func generateFeed() -> [FeedItem] { let feed: [FeedItem] switch selectedTab { @@ -70,9 +86,13 @@ struct UserFeedView: View { feed = generateCommentFeed() case .posts: feed = generatePostFeed() + default: + feed = [] } - return feed + return feed.sorted(by: { + $0.published > $1.published + }) } private func postEntry(for post: PostModel) -> some View { @@ -104,17 +124,21 @@ struct UserFeedView: View { } } + var emptyFeedText: String { + if isOwnProfile { + return "Nothing to see here, get out there and make some stuff!" + } else { + return "Nothing to see here." + } + } + @ViewBuilder private var emptyFeed: some View { - HStack { - Spacer() - Text("Nothing to see here, get out there and make some stuff!") - .padding() - .font(.headline) - .opacity(0.5) - Spacer() - } - .background() + Text(emptyFeedText) + .padding() + .font(.headline) + .opacity(0.5) + .multilineTextAlignment(.center) } private func generateCommentFeed(savedItems: Bool = false) -> [FeedItem] { @@ -126,7 +150,7 @@ struct UserFeedView: View { } else { // If we unfavorited something while // here we don't want it showing up in our feed - return $0.commentView.creator.id == userID + return $0.commentView.creator.id == user.userId } } @@ -151,7 +175,7 @@ struct UserFeedView: View { } else { // If we unfavorited something while // here we don't want it showing up in our feed - return $0.creator.userId == userID + return $0.creator.userId == user.userId } } diff --git a/Mlem/Views/Tabs/Profile/UserHeaderView.swift b/Mlem/Views/Tabs/Profile/UserHeaderView.swift new file mode 100644 index 000000000..1ab30c4dc --- /dev/null +++ b/Mlem/Views/Tabs/Profile/UserHeaderView.swift @@ -0,0 +1,74 @@ +// +// UserHeaderView.swift +// Mlem +// +// Created by Sjmarf on 27/12/2023. +// + +import SwiftUI +import NukeUI + +struct UserHeaderView: View { + @AppStorage("shouldShowUserHeaders") var shouldShowUserHeaders: Bool = true + @AppStorage("shouldShowUserAvatars") var shouldShowUserAvatars: Bool = true + + let user: UserModel + + static let bannerHeight: CGFloat = 170 + static let avatarOverdraw: CGFloat = 40 + static let avatarSize: CGFloat = 108 + static let avatarPadding: CGFloat = AppConstants.postAndCommentSpacing + + var body: some View { + Group { + if let banner = user.banner, shouldShowUserHeaders { + ZStack(alignment: .bottom) { + VStack { + LazyImage(url: banner) { state in + VStack { + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .clipped() + } else { + Color.secondarySystemBackground + } + } + .frame(minWidth: 0, maxWidth: .infinity) + .frame(height: UserHeaderView.bannerHeight) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: AppConstants.largeItemCornerRadius)) + .mask { + ZStack(alignment: .bottom) { + Color.black + if shouldShowUserAvatars { + Circle() + .frame( + width: UserHeaderView.avatarSize + UserHeaderView.avatarPadding * 2, + height: UserHeaderView.avatarSize + UserHeaderView.avatarPadding * 2 + ) + .offset(y: UserHeaderView.avatarOverdraw + UserHeaderView.avatarPadding) + .blendMode(.destinationOut) + } + } + .compositingGroup() + } + + } + Spacer() + } + if shouldShowUserAvatars { + AvatarView(user: user, avatarSize: UserHeaderView.avatarSize, lineWidth: 0, iconResolution: .unrestricted) + } + } + .frame(height: UserHeaderView.bannerHeight + (shouldShowUserAvatars ? UserHeaderView.avatarOverdraw : 0)) + } else { + if shouldShowUserAvatars { + AvatarView(user: user, avatarSize: UserHeaderView.avatarSize, lineWidth: 0, iconResolution: .unrestricted) + .padding(.top) + } + } + } + } +} diff --git a/Mlem/Views/Tabs/Profile/UserView+Logic.swift b/Mlem/Views/Tabs/Profile/UserView+Logic.swift new file mode 100644 index 000000000..2fd5627eb --- /dev/null +++ b/Mlem/Views/Tabs/Profile/UserView+Logic.swift @@ -0,0 +1,89 @@ +// +// UserView+Logic.swift +// Mlem +// +// Created by Sjmarf on 27/12/2023. +// + +import SwiftUI + +extension UserView { + var isOwnProfile: Bool { user.userId == siteInformation.myUserInfo?.localUserView.person.id } + + var tabs: [UserViewTab] { + var tabs: [UserViewTab] = [.overview, .posts, .comments] + if isOwnProfile { + tabs.append(.saved) + } + if !(user.moderatedCommunities?.isEmpty ?? true) { + tabs.append(.communities) + } + return tabs + } + + var cakeDayFormatter: DateFormatter { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "ddMMYY", options: 0, locale: Locale.current) + return dateFormatter + } + + var bioAlignment: TextAlignment { + if let bio = user.bio { + if bio.rangeOfCharacter(from: CharacterSet.newlines) != nil { + return .leading + } + if bio.count > 100 { + return .leading + } + } + return .center + } + + func tryReloadUser() async { + do { + let authoredContent = try await personRepository.loadUserDetails(for: user.userId, limit: internetSpeed.pageSize) + self.user = UserModel(from: authoredContent) + self.communityTracker.replaceAll(with: user.moderatedCommunities?.map { AnyContentModel($0) } ?? []) + + var savedContentData: GetPersonDetailsResponse? + if isOwnProfile { + savedContentData = try await personRepository.loadUserDetails( + for: user.userId, + limit: internetSpeed.pageSize, + savedOnly: true + ) + } + + // accumulate comments and posts so we don't update state more than we need to + var newComments = authoredContent.comments + .sorted(by: { $0.comment.published > $1.comment.published }) + .map { HierarchicalComment(comment: $0, children: [], parentCollapsed: false, collapsed: false) } + + var newPosts = authoredContent.posts.map { PostModel(from: $0) } + + // add saved content, if present + if let savedContent = savedContentData { + newComments.append(contentsOf: + savedContent.comments + .sorted(by: { $0.comment.published > $1.comment.published }) + .map { HierarchicalComment(comment: $0, children: [], parentCollapsed: false, collapsed: false) }) + + newPosts.append(contentsOf: savedContent.posts.map { PostModel(from: $0) }) + } + + privateCommentTracker.comments = newComments + privatePostTracker.reset(with: newPosts) + + self.isLoadingContent = false + + } catch { + errorHandler.handle( + .init( + title: "Couldn't load user info", + message: "There was an error while loading user information.\nTry again later.", + underlyingError: error + ) + ) + } + } +} diff --git a/Mlem/Views/Tabs/Profile/UserView.swift b/Mlem/Views/Tabs/Profile/UserView.swift new file mode 100644 index 000000000..4b3b6a29b --- /dev/null +++ b/Mlem/Views/Tabs/Profile/UserView.swift @@ -0,0 +1,284 @@ +// +// NewUserView.swift +// Mlem +// +// Created by Sjmarf on 27/12/2023. +// + +import SwiftUI +import Dependencies + +struct UserView: View { + @Dependency(\.apiClient) var apiClient + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.notifier) var notifier + @Dependency(\.personRepository) var personRepository + @Dependency(\.siteInformation) var siteInformation + + @Environment(\.navigationPathWithRoutes) private var navigationPath + @Environment(\.scrollViewProxy) private var scrollViewProxy + + let internetSpeed: InternetSpeed + let communityContext: CommunityModel? + + @State var user: UserModel + @State var selectedTab: UserViewTab = .overview + @State var isLoadingContent: Bool = true + + @State var isPresentingAccountSwitcher: Bool = false + + @StateObject var privatePostTracker: PostTracker + @StateObject var privateCommentTracker: CommentTracker = .init() + + // We have to use AnyContentModel instead of CommunityModel here because of the way CommunityResultView is written... hopefully we'll find a better solution than this once we do a class-based middleware rewrite - Sjmarf 2023-12-29 + @StateObject var communityTracker: ContentTracker = .init() + + @State private var isPresentingConfirmDestructive: Bool = false + @State private var confirmationMenuFunction: StandardMenuFunction? + + @Namespace var scrollToTop + @State private var scrollToTopAppeared = false + + func confirmDestructive(destructiveFunction: StandardMenuFunction) { + confirmationMenuFunction = destructiveFunction + isPresentingConfirmDestructive = true + } + + init(user: UserModel, communityContext: CommunityModel? = nil) { + @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast + @AppStorage("upvoteOnSave") var upvoteOnSave = false + + self.internetSpeed = internetSpeed + + self._privatePostTracker = StateObject(wrappedValue: .init( + shouldPerformMergeSorting: false, + internetSpeed: internetSpeed, + upvoteOnSave: upvoteOnSave + )) + + self._user = State(wrappedValue: user) + self.communityContext = communityContext + } + + var body: some View { + ScrollView { + ScrollToView(appeared: $scrollToTopAppeared) + .id(scrollToTop) + VStack(spacing: AppConstants.postAndCommentSpacing) { + UserHeaderView(user: user) + .padding(.horizontal, AppConstants.postAndCommentSpacing) + .padding(.top, 10) + Button(action: user.copyFullyQualifiedUsername) { + VStack(spacing: 5) { + Text(user.displayName) + .font(.title.bold()) + .lineLimit(1) + .minimumScaleFactor(0.01) + Text("@\(user.name)@\(user.profileUrl.host() ?? "unknown")") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, AppConstants.postAndCommentSpacing) + .buttonStyle(.plain) + + flairs + .padding(.bottom, AppConstants.postAndCommentSpacing) + + VStack(spacing: 0) { + let bioAlignment = bioAlignment + if let bio = user.bio { + Divider() + .padding(.bottom, AppConstants.postAndCommentSpacing) + MarkdownView(text: bio, isNsfw: false, alignment: bioAlignment).padding(AppConstants.postAndCommentSpacing) + + } + HStack { + Label(cakeDayFormatter.string(from: user.creationDate), systemImage: Icons.cakeDay) + Text("•") + Label(user.creationDate.getRelativeTime(date: Date.now, unitsStyle: .abbreviated), systemImage: Icons.time) + if bioAlignment == .leading { + Spacer() + } + } + .foregroundStyle(.secondary) + .font(.footnote) + .padding(.horizontal, AppConstants.postAndCommentSpacing) + .padding(.top, 2) + + Divider() + .padding(.top, AppConstants.postAndCommentSpacing * 2) + + if isLoadingContent { + VStack(spacing: 0) { + LoadingView(whatIsLoading: .content) + } + .transition(.opacity) + } else { + VStack(spacing: 0) { + ScrollView(.horizontal) { + BubblePicker(tabs, selected: $selectedTab) { tab in + switch tab { + case .posts: + Text("Posts (\(abbreviateNumber(user.postCount ?? 0)))") + case .comments: + Text("Comments (\(abbreviateNumber(user.commentCount ?? 0)))") + case .communities: + Text("Communities (\(abbreviateNumber(user.moderatedCommunities?.count ?? 0)))") + default: + Text(tab.label) + } + } + .padding(.horizontal, AppConstants.postAndCommentSpacing) + .padding(.vertical, 4) + } + .scrollIndicators(.hidden) + Divider() + UserFeedView( + user: user, + privatePostTracker: privatePostTracker, + privateCommentTracker: privateCommentTracker, + communityTracker: communityTracker, + selectedTab: $selectedTab + ) + } + .transition(.opacity) + } + } + .animation(.easeOut(duration: 0.2), value: isLoadingContent) + } + } + .environmentObject(privatePostTracker) + .environmentObject(privateCommentTracker) + .destructiveConfirmation( + isPresentingConfirmDestructive: $isPresentingConfirmDestructive, + confirmationMenuFunction: confirmationMenuFunction + ) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + let functions = user.menuFunctions { user = $0 } + if functions.count == 1, let first = functions.first { + MenuButton(menuFunction: first, confirmDestructive: confirmDestructive) + } else { + Menu { + ForEach(functions) { item in + MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) + } + } label: { + Label("Menu", systemImage: Icons.menu) + } + } + } + if isOwnProfile { + ToolbarItem(placement: .topBarLeading) { + Button("Switch Account", systemImage: Icons.switchUser) { + isPresentingAccountSwitcher = true + } + } + } + } + .task(priority: .userInitiated) { + if isLoadingContent { + await tryReloadUser() + } + } + .onChange(of: user.userId) { _ in + Task { + await tryReloadUser() + } + } + .refreshable { + await Task { + await tryReloadUser() + }.value + } + .hoistNavigation { + if navigationPath.isEmpty { + withAnimation { + scrollViewProxy?.scrollTo(scrollToTop) + } + return true + } else { + if scrollToTopAppeared { + return false + } else { + withAnimation { + scrollViewProxy?.scrollTo(scrollToTop) + } + return true + } + } + } + .fancyTabScrollCompatible() + .navigationTitle(user.displayName) + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $isPresentingAccountSwitcher) { + Form { + AccountListView() + } + } + } + + var flairs: some View { + VStack(spacing: AppConstants.postAndCommentSpacing) { + ForEach(user.getFlairs(communityContext: communityContext), id: \.self) { flair in + switch flair { + case .developer: + flairBackground(color: flair.color) { + HStack { + Image(systemName: Icons.developerFlair) + Text("Mlem Developer") + } + } + case .banned: + flairBackground(color: flair.color) { + HStack { + Image(systemName: Icons.bannedFlair) + if let expirationDate = user.banExpirationDate { + Text("Banned Until \(cakeDayFormatter.string(from: expirationDate))") + } else { + Text("Permanently Banned") + } + } + } + case .bot: + flairBackground(color: flair.color) { + HStack { + Image(systemName: Icons.botFlair) + Text("Bot Account") + } + } + case .admin: + flairBackground(color: flair.color) { + HStack { + Image(systemName: Icons.adminFlair) + let host = try? apiClient.session.instanceUrl.host() + Text("\(host ?? "Instance") Administrator") + } + } + case .moderator: + flairBackground(color: flair.color) { + HStack { + Image(systemName: Icons.moderationFill) + Text("\(communityContext?.displayName ?? "Community") Moderator") + } + } + default: + EmptyView() + } + } + } + } + + @ViewBuilder + func flairBackground(color: Color, @ViewBuilder content: () -> Content) -> some View { + content() + .foregroundStyle(color) + .padding(.vertical, 8) + .padding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: AppConstants.largeItemCornerRadius).fill(color.opacity(0.2)) + ) + .padding(.horizontal, AppConstants.postAndCommentSpacing) + } +} diff --git a/Mlem/Views/Tabs/Search/SearchTabPicker.swift b/Mlem/Views/Tabs/Search/BubblePicker.swift similarity index 68% rename from Mlem/Views/Tabs/Search/SearchTabPicker.swift rename to Mlem/Views/Tabs/Search/BubblePicker.swift index af6b6d4de..43882440b 100644 --- a/Mlem/Views/Tabs/Search/SearchTabPicker.swift +++ b/Mlem/Views/Tabs/Search/BubblePicker.swift @@ -8,26 +8,22 @@ import SwiftUI import Dependencies -enum SearchTab: String, CaseIterable { - case topResults, communities, users - - var label: String { - switch self { - case .topResults: - return "Top Results" - default: - return rawValue.capitalized - } - } - - static var homePageCases: [SearchTab] = [.communities, .users] -} - -struct SearchTabPicker: View { +struct BubblePicker: View { @Dependency(\.hapticManager) var hapticManager - @Binding var selected: SearchTab - var tabs: [SearchTab] = SearchTab.allCases + @Binding var selected: Value + let tabs: [Value] + @ViewBuilder let labelBuilder: (Value) -> any View + + init( + _ tabs: [Value], + selected: Binding, + @ViewBuilder labelBuilder: @escaping (Value) -> any View + ) { + self._selected = selected + self.tabs = tabs + self.labelBuilder = labelBuilder + } var body: some View { HStack(spacing: 0) { @@ -36,10 +32,9 @@ struct SearchTabPicker: View { selected = type hapticManager.play(haptic: .gentleInfo, priority: .low) } label: { - Text(type.label) + AnyView(labelBuilder(type)) .padding(.vertical, 6) .padding(.horizontal, 12) - .contentShape(Rectangle()) .foregroundStyle(selected == type ? .white : .primary) .font(.subheadline) .fontWeight(.semibold) @@ -53,6 +48,8 @@ struct SearchTabPicker: View { } ) .animation(.spring(response: 0.15, dampingFraction: 0.7), value: selected) + .padding(.vertical, 4) + .contentShape(Rectangle()) } .buttonStyle(EmptyButtonStyle()) } @@ -61,8 +58,10 @@ struct SearchTabPicker: View { } #Preview { - SearchTabPicker( - selected: .constant(.communities), - tabs: SearchTab.homePageCases - ) + BubblePicker( + SearchTab.allCases, + selected: .constant(.communities) + ) { + Text($0.label) + } } diff --git a/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift b/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift index 06abe20d1..7a4c6c108 100644 --- a/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift +++ b/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift @@ -62,6 +62,7 @@ struct CommunityResultView: View { VStack(alignment: .leading, spacing: 4) { Text(title) + .lineLimit(1) .foregroundStyle(community.nsfw ? .red : .primary) Text(caption) .font(.footnote) @@ -69,12 +70,14 @@ struct CommunityResultView: View { .lineLimit(1) } Spacer() - HStack(spacing: 5) { - Text(abbreviateNumber(community.subscriberCount ?? 0)) - .monospacedDigit() - Image(systemName: (community.subscribed ?? false) ? Icons.subscribed : Icons.personFill) + if let subscriberCount = community.subscriberCount { + HStack(spacing: 5) { + Text(abbreviateNumber(subscriberCount)) + .monospacedDigit() + Image(systemName: (community.subscribed ?? false) ? Icons.subscribed : Icons.personFill) + } + .foregroundStyle((community.subscribed ?? false) ? .green : .secondary) } - .foregroundStyle((community.subscribed ?? false) ? .green : .secondary) Image(systemName: Icons.forward) .imageScale(.small) .foregroundStyle(.tertiary) diff --git a/Mlem/Views/Tabs/Search/Results/UserResultView.swift b/Mlem/Views/Tabs/Search/Results/UserResultView.swift index b25c8bbf4..b14d27829 100644 --- a/Mlem/Views/Tabs/Search/Results/UserResultView.swift +++ b/Mlem/Views/Tabs/Search/Results/UserResultView.swift @@ -28,9 +28,9 @@ struct UserResultView: View { var title: String { if user.blocked { - return "\(user.name) ∙ Blocked" + return "\(user.displayName) ∙ Blocked" } else { - return user.name + return user.displayName } } @@ -68,6 +68,7 @@ struct UserResultView: View { .foregroundStyle(flair.color) } Text(title) + .lineLimit(1) } Text(caption) .font(.footnote) diff --git a/Mlem/Views/Tabs/Search/SearchHomeView.swift b/Mlem/Views/Tabs/Search/SearchHomeView.swift index 7a68f1163..016936bb3 100644 --- a/Mlem/Views/Tabs/Search/SearchHomeView.swift +++ b/Mlem/Views/Tabs/Search/SearchHomeView.swift @@ -21,11 +21,12 @@ struct SearchHomeView: View { .padding(.horizontal, 18) .padding(.top, 12) ScrollView(.horizontal) { - SearchTabPicker(selected: $searchModel.searchTab, tabs: SearchTab.homePageCases) - .padding(.horizontal) + BubblePicker(SearchTab.homePageCases, selected: $searchModel.searchTab) { + Text($0.label) + } + .padding(.horizontal) } .scrollIndicators(.hidden) - .padding(.top, 8) .padding(.bottom, 12) Divider() SearchResultListView(showTypeLabel: false) diff --git a/Mlem/Views/Tabs/Search/SearchResultsView.swift b/Mlem/Views/Tabs/Search/SearchResultsView.swift index c17ca9856..f70380f76 100644 --- a/Mlem/Views/Tabs/Search/SearchResultsView.swift +++ b/Mlem/Views/Tabs/Search/SearchResultsView.swift @@ -42,8 +42,10 @@ struct SearchResultsView: View { private var tabs: some View { HStack { ScrollView(.horizontal) { - SearchTabPicker(selected: $searchModel.searchTab) - .padding(.horizontal) + BubblePicker(SearchTab.allCases, selected: $searchModel.searchTab) { + Text($0.label) + } + .padding(.horizontal) } .scrollIndicators(.hidden) Group { @@ -55,7 +57,6 @@ struct SearchResultsView: View { } .animation(.default, value: contentTracker.isLoading) } - .padding(.vertical, 4) } } diff --git a/Mlem/Views/Tabs/Settings/Components/AccountButtonView.swift b/Mlem/Views/Tabs/Settings/Components/AccountButtonView.swift index 332498791..4d381d817 100644 --- a/Mlem/Views/Tabs/Settings/Components/AccountButtonView.swift +++ b/Mlem/Views/Tabs/Settings/Components/AccountButtonView.swift @@ -13,6 +13,7 @@ struct AccountButtonView: View { @EnvironmentObject var appState: AppState @Dependency(\.accountsTracker) var accountsTracker: SavedAccountTracker @Environment(\.setAppFlow) private var setFlow + @Environment(\.dismiss) var dismiss @State var showingSignOutConfirmation: Bool = false @@ -126,6 +127,7 @@ struct AccountButtonView: View { private func setFlow(using account: SavedAccount?) { if let account { + dismiss() setFlow(.account(account)) return } diff --git a/Mlem/Views/Tabs/Settings/Components/AccountListView+Logic.swift b/Mlem/Views/Tabs/Settings/Components/AccountListView+Logic.swift index 431809261..845d475b7 100644 --- a/Mlem/Views/Tabs/Settings/Components/AccountListView+Logic.swift +++ b/Mlem/Views/Tabs/Settings/Components/AccountListView+Logic.swift @@ -20,7 +20,7 @@ extension AccountListView { if appState.currentActiveAccount == $0 { return true } else if appState.currentActiveAccount == $1 { - return true + return false } return $0.lastUsed ?? .distantPast > $1.lastUsed ?? .distantPast } diff --git a/Mlem/Window.swift b/Mlem/Window.swift index a6848658d..49da35c05 100644 --- a/Mlem/Window.swift +++ b/Mlem/Window.swift @@ -52,6 +52,7 @@ struct Window: View { favoriteCommunitiesTracker.clearStoredAccount() case let .account(account): var account = account + siteInformation.myUserInfo = nil appState.setActiveAccount(account, saveChanges: false) siteInformation.load(account: account) favoriteCommunitiesTracker.configure(for: account)