diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index e3ffebf51..02c8520e7 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ 031A93D62AC847DA0077030C /* UploadConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031A93D52AC847DA0077030C /* UploadConfirmationView.swift */; }; 031BF9532AB24BAF00F4517F /* SiteVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031BF9522AB24BAF00F4517F /* SiteVersion.swift */; }; 031BF9552AB25AFB00F4517F /* SiteVersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031BF9542AB25AFB00F4517F /* SiteVersionTests.swift */; }; + 031F95572B5C7FF20069C244 /* InstanceDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031F95562B5C7FF20069C244 /* InstanceDetailsView.swift */; }; 032109472AA7C3FC00912DFC /* CommunityLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032109462AA7C3FC00912DFC /* CommunityLabelView.swift */; }; 032109492AA7C41800912DFC /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032109482AA7C41800912DFC /* AvatarView.swift */; }; 032C1E042B5D7DAC00FB4F23 /* QuickSwitcherSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032C1E032B5D7DAC00FB4F23 /* QuickSwitcherSettingsView.swift */; }; @@ -271,7 +272,7 @@ 63F0C7B92A0533C700A18C5D /* Add Account View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F0C7B82A0533C700A18C5D /* Add Account View.swift */; }; 63F0C7BD2A058CD200A18C5D /* Check if Endpoint Exists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F0C7BC2A058CD200A18C5D /* Check if Endpoint Exists.swift */; }; 63F0C7BF2A058EDE00A18C5D /* Get Correct URL to Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F0C7BE2A058EDE00A18C5D /* Get Correct URL to Endpoint.swift */; }; - 6D15D74C2A44DC240061B5CB /* Date+RelativeTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D15D74B2A44DC240061B5CB /* Date+RelativeTime.swift */; }; + 6D15D74C2A44DC240061B5CB /* Date+Formatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D15D74B2A44DC240061B5CB /* Date+Formatter.swift */; }; 6D405AFF2A43E66600C65F9C /* UserLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D405AFE2A43E66600C65F9C /* UserLabelView.swift */; }; 6D693A3E2A5113DF009E2D76 /* CreatePostReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D693A3D2A5113DF009E2D76 /* CreatePostReport.swift */; }; 6D693A402A51147E009E2D76 /* APIPostReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D693A3F2A51147E009E2D76 /* APIPostReportView.swift */; }; @@ -593,6 +594,7 @@ 031A93D52AC847DA0077030C /* UploadConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadConfirmationView.swift; sourceTree = ""; }; 031BF9522AB24BAF00F4517F /* SiteVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteVersion.swift; sourceTree = ""; }; 031BF9542AB25AFB00F4517F /* SiteVersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteVersionTests.swift; sourceTree = ""; }; + 031F95562B5C7FF20069C244 /* InstanceDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceDetailsView.swift; sourceTree = ""; }; 032109462AA7C3FC00912DFC /* CommunityLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLabelView.swift; sourceTree = ""; }; 032109482AA7C41800912DFC /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = ""; }; 032C1E032B5D7DAC00FB4F23 /* QuickSwitcherSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSwitcherSettingsView.swift; sourceTree = ""; }; @@ -831,7 +833,7 @@ 63F0C7B82A0533C700A18C5D /* Add Account View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Add Account View.swift"; sourceTree = ""; }; 63F0C7BC2A058CD200A18C5D /* Check if Endpoint Exists.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Check if Endpoint Exists.swift"; sourceTree = ""; }; 63F0C7BE2A058EDE00A18C5D /* Get Correct URL to Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Get Correct URL to Endpoint.swift"; sourceTree = ""; }; - 6D15D74B2A44DC240061B5CB /* Date+RelativeTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+RelativeTime.swift"; sourceTree = ""; }; + 6D15D74B2A44DC240061B5CB /* Date+Formatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Formatter.swift"; sourceTree = ""; }; 6D405AFE2A43E66600C65F9C /* UserLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLabelView.swift; sourceTree = ""; }; 6D693A3D2A5113DF009E2D76 /* CreatePostReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePostReport.swift; sourceTree = ""; }; 6D693A3F2A51147E009E2D76 /* APIPostReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIPostReportView.swift; sourceTree = ""; }; @@ -1366,6 +1368,7 @@ isa = PBXGroup; children = ( 03A54C312B5331F30064CCDE /* InstanceView.swift */, + 031F95562B5C7FF20069C244 /* InstanceDetailsView.swift */, ); path = Instance; sourceTree = ""; @@ -2291,7 +2294,7 @@ CD29ED2E2B2E8307006937CE /* Date */ = { isa = PBXGroup; children = ( - 6D15D74B2A44DC240061B5CB /* Date+RelativeTime.swift */, + 6D15D74B2A44DC240061B5CB /* Date+Formatter.swift */, ); path = Date; sourceTree = ""; @@ -3412,6 +3415,7 @@ CDC1C93C2A7AA76000072E3D /* InternetSpeed.swift in Sources */, 50EC39B22A346DDC00E014C2 /* URLHandler.swift in Sources */, 63F0C7BF2A058EDE00A18C5D /* Get Correct URL to Endpoint.swift in Sources */, + 031F95572B5C7FF20069C244 /* InstanceDetailsView.swift in Sources */, 632E8EE827EE63DB007E8D75 /* DownvoteButtonView.swift in Sources */, 50D61E5B2AA32B9400A926EC /* APISession.swift in Sources */, CDCBD72B2A8EC0A800387A2C /* Instance Summary.swift in Sources */, @@ -3472,6 +3476,7 @@ 03A54C322B5331F30064CCDE /* InstanceView.swift in Sources */, 6D8F08FF2A4029AE003EB4FD /* CommunityListSection.swift in Sources */, 6D8F08FF2A4029AE003EB4FD /* CommunityListSection.swift in Sources */, + 6D8F08FF2A4029AE003EB4FD /* CommunityListSection.swift in Sources */, 035EB0CA2A8687C200227859 /* JumpButtonView.swift in Sources */, 5016A2B12A67EB8600B257E8 /* UIViewController+TopMostViewController.swift in Sources */, 6372184C2A3A2AAD008C4816 /* APIPostView.swift in Sources */, @@ -3574,7 +3579,7 @@ E49E01F42ABD99D300E42BB3 /* Routable.swift in Sources */, 03F4DC9F2B1A8AD500556C67 /* SignInAndSecuritySettingsView.swift in Sources */, 03F76FA62B2F5F4700E2B54A /* LinkUploadOptionsView.swift in Sources */, - 6D15D74C2A44DC240061B5CB /* Date+RelativeTime.swift in Sources */, + 6D15D74C2A44DC240061B5CB /* Date+Formatter.swift in Sources */, CDA217E62A63016A00BDA173 /* ReportMessage.swift in Sources */, CD9DD8832A622A6C0044EA8E /* ReportCommentReply.swift in Sources */, 6FB4A4DE2B47860B00A7CD82 /* CollapsedCommentReplies.swift in Sources */, diff --git a/Mlem/API/Models/Community/APICommunityAggregates.swift b/Mlem/API/Models/Community/APICommunityAggregates.swift index a529a61c2..2c2f4c5a0 100644 --- a/Mlem/API/Models/Community/APICommunityAggregates.swift +++ b/Mlem/API/Models/Community/APICommunityAggregates.swift @@ -12,6 +12,7 @@ struct APICommunityAggregates: Decodable { let id: Int? // TODO: 0.18 Deprecation remove this field let communityId: Int let subscribers: Int + let subscribersLocal: Int? let posts: Int let comments: Int let published: Date diff --git a/Mlem/API/Models/Site/APILocalSite.swift b/Mlem/API/Models/Site/APILocalSite.swift index 33df92f6d..9d1b4ccb0 100644 --- a/Mlem/API/Models/Site/APILocalSite.swift +++ b/Mlem/API/Models/Site/APILocalSite.swift @@ -5,7 +5,9 @@ // Created by Jonathan de Jong on 12/06/2023. // -import Foundation +import SwiftUI + +enum APICaptchaDifficulty: String, Codable { case easy, medium, hard } // lemmy_db_schema::source::local_site::LocalSite struct APILocalSite: Decodable { @@ -13,32 +15,62 @@ struct APILocalSite: Decodable { // let siteId: Int // let siteSetup: Bool let enableDownvotes: Bool -// let enableNsfw: Bool -// let communityCreationAdminOnly: Bool -// let requireEmailVerification: Bool + let enableNsfw: Bool + let communityCreationAdminOnly: Bool + let requireEmailVerification: Bool // let applicationQuestion: String? -// let privateInstance: Bool + let privateInstance: Bool // let defaultTheme: String -// let defaultPostListingType: String + let defaultPostListingType: APIListingType // let legalInformation: String? -// let hideModlogModNames: Bool -// let applicationEmailAdmins: Bool + let hideModlogModNames: Bool + let applicationEmailAdmins: Bool let slurFilterRegex: String? // let actorNameMaxLength: Int -// let federationEnabled: Bool -// let federationDebug: Bool -// let federationWorkerCount: Int -// let captchaEnabled: Bool -// let captchaDifficulty: String -// let registrationMode: APIRegistrationMode -// let reportsEmailAdmins: Bool -// let published: Date + let federationEnabled: Bool + let federationSignedFetch: Bool? + let captchaEnabled: Bool + let captchaDifficulty: APICaptchaDifficulty + let registrationMode: APIRegistrationMode + let reportsEmailAdmins: Bool + let published: Date // let updated: Date? } // lemmy_db_schema::source::local_site::RegistrationMode enum APIRegistrationMode: String, Codable { - case closed = "Closed" - case requireApplication = "RequireApplication" - case open = "Open" + case closed = "closed" + case requireApplication = "requireapplication" + case open = "open" + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let stringValue = try? container.decode(String.self) { + if let item = APIRegistrationMode(rawValue: stringValue.lowercased()) { + self = item + return + } + } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid APIRegistrationMode value") + } + + var label: String { + switch self { + case .requireApplication: + return "Requires Application" + default: + return rawValue.capitalized + } + } + + var color: Color { + switch self { + case .closed: + return .red + case .requireApplication: + return .orange + case .open: + return .green + } + } } diff --git a/Mlem/Extensions/Date/Date+Formatter.swift b/Mlem/Extensions/Date/Date+Formatter.swift new file mode 100644 index 000000000..2aa5784a3 --- /dev/null +++ b/Mlem/Extensions/Date/Date+Formatter.swift @@ -0,0 +1,25 @@ +// +// Date+RelativeTime.swift +// Mlem +// +// Created by Jake Shirley on 6/22/23. +// + +import SwiftUI + +extension Date { + // Returns strings like "3 seconds ago" and "10 days ago" + func getRelativeTime(date: Date = .now, unitsStyle: RelativeDateTimeFormatter.UnitsStyle = .full) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = unitsStyle + + return formatter.localizedString(for: self, relativeTo: date) + } + + // Returns strings like "5/10/2023" + var dateString: String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "ddMMYY", options: 0, locale: Locale.current) + return dateFormatter.string(from: self) + } +} diff --git a/Mlem/Extensions/Date/Date+RelativeTime.swift b/Mlem/Extensions/Date/Date+RelativeTime.swift deleted file mode 100644 index 3561d0765..000000000 --- a/Mlem/Extensions/Date/Date+RelativeTime.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Date+RelativeTime.swift -// Mlem -// -// Created by Jake Shirley on 6/22/23. -// - -import SwiftUI - -extension Date { - // Returns strings like "3 seconds ago" and "10 days ago" - func getRelativeTime(date: Date, unitsStyle: RelativeDateTimeFormatter.UnitsStyle = .full) -> String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = unitsStyle - - return formatter.localizedString(for: self, relativeTo: date) - } -} diff --git a/Mlem/Extensions/Mocks/APICommunityAggregates+Mock.swift b/Mlem/Extensions/Mocks/APICommunityAggregates+Mock.swift index 7be6d9f64..70e1ac209 100644 --- a/Mlem/Extensions/Mocks/APICommunityAggregates+Mock.swift +++ b/Mlem/Extensions/Mocks/APICommunityAggregates+Mock.swift @@ -13,6 +13,7 @@ extension APICommunityAggregates { id: Int = 0, communityId: Int = 0, subscribers: Int = 42349, + subscribersLocal: Int = 2043, posts: Int = 300, comments: Int = 5000, published: Date = .mock, @@ -25,6 +26,7 @@ extension APICommunityAggregates { id: id, communityId: communityId, subscribers: subscribers, + subscribersLocal: subscribersLocal, posts: posts, comments: comments, published: published, diff --git a/Mlem/Icons.swift b/Mlem/Icons.swift index a6fc3e35b..3bf3c9362 100644 --- a/Mlem/Icons.swift +++ b/Mlem/Icons.swift @@ -108,6 +108,7 @@ enum Icons { static let bannedFlair: String = "multiply.circle" // entities/general Lemmy concepts + static let federation: String = "point.3.filled.connected.trianglepath.dotted" static let instance: String = "server.rack" static let user: String = "person.crop.circle" static let userFill: String = "person.crop.circle.fill" @@ -143,6 +144,7 @@ enum Icons { static let favoriteFill: String = "star.fill" static let unfavorite: String = "star.slash" static let unfavoriteFill: String = "star.slash.fill" + static let person: String = "person" static let personFill: String = "person.fill" static let close: String = "multiply" static let cakeDay: String = "birthday.cake" @@ -189,6 +191,9 @@ enum Icons { static let collapseComments: String = "arrow.down.and.line.horizontal.and.arrow.up" // misc + static let `private`: String = "lock" + static let email: String = "envelope" + static let photo: String = "photo" static let switchUser: String = "person.crop.circle.badge.plus" static let missing: String = "questionmark.square.dashed" static let connection: String = "antenna.radiowaves.left.and.right" diff --git a/Mlem/Models/Content/Community/CommunityModel.swift b/Mlem/Models/Content/Community/CommunityModel.swift index 9a9e4abd5..970d82f46 100644 --- a/Mlem/Models/Content/Community/CommunityModel.swift +++ b/Mlem/Models/Content/Community/CommunityModel.swift @@ -8,6 +8,13 @@ import Dependencies import SwiftUI +struct ActiveUserCount { + let sixMonths: Int + let month: Int + let week: Int + let day: Int +} + struct CommunityModel { @Dependency(\.apiClient) private var apiClient @Dependency(\.errorHandler) var errorHandler @@ -20,13 +27,6 @@ struct CommunityModel { case noData } - struct ActiveUserCount { - let sixMonths: Int - let month: Int - let week: Int - let day: Int - } - @available(*, deprecated, message: "Use attributes of the CommunityModel directly instead.") var community: APICommunity! @@ -63,6 +63,7 @@ struct CommunityModel { var blocked: Bool? var subscribed: Bool? var subscriberCount: Int? + var localSubscriberCount: Int? var postCount: Int? var commentCount: Int? var activeUserCount: ActiveUserCount? @@ -108,8 +109,8 @@ struct CommunityModel { mutating func update(with communityView: APICommunityView) { subscribed = communityView.subscribed.isSubscribed blocked = communityView.blocked - subscriberCount = communityView.counts.subscribers + localSubscriberCount = communityView.counts.subscribersLocal postCount = communityView.counts.posts commentCount = communityView.counts.comments activeUserCount = .init( diff --git a/Mlem/Models/Content/Instance/InstanceModel.swift b/Mlem/Models/Content/Instance/InstanceModel.swift index 1bd7b68c1..750202eef 100644 --- a/Mlem/Models/Content/Instance/InstanceModel.swift +++ b/Mlem/Models/Content/Instance/InstanceModel.swift @@ -16,13 +16,40 @@ struct InstanceModel { var administrators: [UserModel]? var url: URL! var version: SiteVersion? + var creationDate: Date! + // From APISiteView + var userCount: Int? + var communityCount: Int? + var postCount: Int? + var commentCount: Int? + var activeUserCount: ActiveUserCount? + + // From APILocalSite (only accessible via SiteResponse) + var `private`: Bool? + var federates: Bool? + var federationSignedFetch: Bool? + var allowsDownvotes: Bool? + var allowsNSFW: Bool? + var allowsCommunityCreation: Bool? + var requiresEmailVerification: Bool? var slurFilterRegex: Regex? + var slurFilterString: String? + var captchaDifficulty: APICaptchaDifficulty? + var registrationMode: APIRegistrationMode? + var defaultFeedType: APIListingType? + var hideModlogModNames: Bool? + var applicationsEmailAdmins: Bool? + var reportsEmailAdmins: Bool? init(from response: SiteResponse) { update(with: response) } + init(from siteView: APISiteView) { + self.update(with: siteView) + } + init(from site: APISite) { update(with: site) } @@ -37,16 +64,46 @@ struct InstanceModel { version = SiteVersion(response.version) let localSite = response.siteView.localSite - + self.allowsDownvotes = localSite.enableDownvotes + self.allowsNSFW = localSite.enableNsfw + self.allowsCommunityCreation = !localSite.communityCreationAdminOnly + self.requiresEmailVerification = localSite.requireEmailVerification + self.captchaDifficulty = localSite.captchaEnabled ? localSite.captchaDifficulty : nil + self.private = localSite.privateInstance + self.federates = localSite.federationEnabled + self.federationSignedFetch = localSite.federationSignedFetch + self.defaultFeedType = localSite.defaultPostListingType + self.hideModlogModNames = localSite.hideModlogModNames + self.applicationsEmailAdmins = localSite.applicationEmailAdmins + self.reportsEmailAdmins = localSite.reportsEmailAdmins + + self.registrationMode = localSite.registrationMode do { if let regex = localSite.slurFilterRegex { - slurFilterRegex = try .init(regex) + self.slurFilterString = regex + self.slurFilterRegex = try .init(regex) } } catch { print("Invalid slur filter regex") } - - update(with: response.siteView.site) + + self.update(with: response.siteView) + } + + mutating func update(with siteView: APISiteView) { + userCount = siteView.counts.users + communityCount = siteView.counts.communities + postCount = siteView.counts.posts + commentCount = siteView.counts.comments + + self.activeUserCount = .init( + sixMonths: siteView.counts.usersActiveHalfYear, + month: siteView.counts.usersActiveMonth, + week: siteView.counts.usersActiveWeek, + day: siteView.counts.usersActiveDay + ) + + self.update(with: siteView.site) } mutating func update(with site: APISite) { @@ -55,6 +112,7 @@ struct InstanceModel { description = site.sidebar avatar = site.iconUrl banner = site.bannerUrl + creationDate = site.published if var components = URLComponents(string: site.inboxUrl) { components.path = "" diff --git a/Mlem/Models/UserFlair.swift b/Mlem/Models/UserFlair.swift index fc9a921e1..b000df65e 100644 --- a/Mlem/Models/UserFlair.swift +++ b/Mlem/Models/UserFlair.swift @@ -48,4 +48,21 @@ enum UserFlair { return Icons.developerFlair } } + + var label: String { + switch self { + case .admin: + return "Administrator" + case .bot: + return "Bot Account" + case .banned: + return "Banned" + case .moderator: + return "Moderator" + case .developer: + return "Mlem Developer" + case .op: + return "Original Poster" + } + } } diff --git a/Mlem/Notifications/NotificationDisplayer.swift b/Mlem/Notifications/NotificationDisplayer.swift index 64595b604..46ce63f4e 100644 --- a/Mlem/Notifications/NotificationDisplayer.swift +++ b/Mlem/Notifications/NotificationDisplayer.swift @@ -31,19 +31,6 @@ enum NotificationDisplayer { } } - /// A method to present the user with the token refresh view - /// - Parameters: - /// - account: The current `SavedAccount` for the active session - /// - refreshedAccount: A closure which will receive the updated version of the account with a refreshed access token - static func presentTokenRefreshFlow( - for account: SavedAccount, - refreshedAccount: @escaping (SavedAccount) -> Void - ) { - let tokenRefreshView = TokenRefreshView(account: account, refreshedAccount: refreshedAccount) - let view = UIHostingController(rootView: tokenRefreshView) - present(view) - } - // MARK: - Private methods private static func display(contextualError: ContextualError) async { diff --git a/Mlem/Views/Shared/Instance/InstanceDetailsView.swift b/Mlem/Views/Shared/Instance/InstanceDetailsView.swift new file mode 100644 index 000000000..a2237aaf0 --- /dev/null +++ b/Mlem/Views/Shared/Instance/InstanceDetailsView.swift @@ -0,0 +1,272 @@ +// +// InstanceStatsView.swift +// Mlem +// +// Created by Sjmarf on 20/01/2024. +// + +import SwiftUI + +struct InstanceDetailsView: View { + @State var showingSlurRegex: Bool = false + + let instance: InstanceModel + + var body: some View { + VStack(spacing: 16) { + box { + HStack { + Label(instance.creationDate.dateString, systemImage: Icons.cakeDay) + Text("•") + Label(instance.creationDate.getRelativeTime(unitsStyle: .abbreviated), systemImage: Icons.time) + } + .foregroundStyle(.secondary) + .font(.footnote) + } + HStack(spacing: 16) { + box { + Text("Users") + .foregroundStyle(.secondary) + Text("\(abbreviateNumber(instance.userCount ?? 0))") + .font(.title) + .fontWeight(.semibold) + } + + box { + Text("Communities") + .foregroundStyle(.secondary) + Text("\(abbreviateNumber(instance.communityCount ?? 0))") + .font(.title) + .fontWeight(.semibold) + .foregroundStyle(.green) + } + } + .frame(maxWidth: .infinity) + + HStack(spacing: 16) { + box { + Text("Posts") + .foregroundStyle(.secondary) + Text("\(abbreviateNumber(instance.postCount ?? 0))") + .font(.title) + .fontWeight(.semibold) + .foregroundStyle(.pink) + } + + box { + Text("Comments") + .foregroundStyle(.secondary) + Text("\(abbreviateNumber(instance.commentCount ?? 0))") + .font(.title) + .fontWeight(.semibold) + .foregroundStyle(.orange) + } + } + .frame(maxWidth: .infinity) + + if let activeUserCount = instance.activeUserCount { + box(spacing: 8) { + Text("Active Users") + .foregroundStyle(.secondary) + HStack(spacing: 16) { + activeUserBox("6mo", value: activeUserCount.sixMonths) + activeUserBox("1mo", value: activeUserCount.month) + activeUserBox("1w", value: activeUserCount.week) + activeUserBox("1d", value: activeUserCount.day) + } + } + } + VStack(alignment: .leading, spacing: 0) { + settingRow( + "Private", + systemImage: Icons.private, + value: instance.private ?? false + ) + Divider() + settingRow( + "Federates", + systemImage: Icons.federation, + value: instance.federates ?? false + ) + } + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(AppConstants.largeItemCornerRadius) + + VStack(alignment: .leading, spacing: 0) { + settingRow( + "Registration", + systemImage: Icons.person, + value: instance.registrationMode?.label ?? "Closed", + color: instance.registrationMode?.color ?? .red + ) + if instance.registrationMode != .closed { + Divider() + settingRow( + "Email Verification", + systemImage: Icons.email, + value: instance.requiresEmailVerification ?? false + ) + Divider() + settingRow( + "Captcha", + systemImage: Icons.photo, + value: captchaLabel, + color: instance.captchaDifficulty == nil ? .red : .green + ) + } + } + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(AppConstants.largeItemCornerRadius) + + VStack(alignment: .leading, spacing: 0) { + settingRow( + "NSFW Content", + systemImage: Icons.blurNsfw, + value: instance.allowsNSFW ?? false + ) + Divider() + settingRow( + "Downvotes", + systemImage: Icons.downvote, + value: instance.allowsDownvotes ?? false + ) + Divider() + settingRow( + "Community Creation", + systemImage: "house", + value: instance.allowsCommunityCreation ?? false + ) + Divider() + settingRow( + "Slur Filter", + systemImage: Icons.filterFill, + value: instance.slurFilterRegex != nil + ) + if let regex = instance.slurFilterString { + Divider() + VStack(alignment: .leading, spacing: 2) { + if showingSlurRegex { + Text(regex) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } else { + Text("Tap to show slur filter regex.") + Label( + "This probably contains foul language.", + systemImage: Icons.warning + ) + .foregroundStyle(.orange) + } + } + .font(.footnote) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .contentShape(.rect) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + showingSlurRegex.toggle() + } + } + } + if let feedType = instance.defaultFeedType { + Divider() + settingRow( + "Default Feed Type (Desktop)", + systemImage: Icons.feeds, + value: feedType.rawValue + ) + } + } + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(AppConstants.largeItemCornerRadius) + + VStack(alignment: .leading, spacing: 0) { + settingRow( + "Show Mod Names in Modlog", + systemImage: Icons.moderation, + value: !(instance.hideModlogModNames ?? true) + ) + Divider() + settingRow( + "Applications Email Admins", + systemImage: Icons.person, + value: instance.applicationsEmailAdmins ?? false + ) + Divider() + settingRow( + "Reports Email Admins", + systemImage: Icons.moderationReport, + value: instance.reportsEmailAdmins ?? false + ) + } + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(AppConstants.largeItemCornerRadius) + } + .padding(.horizontal, 16) + } + + var captchaLabel: String { + if let diff = instance.captchaDifficulty { + return "Yes (\(diff.rawValue.capitalized))" + } + return "No" + } + + @ViewBuilder func box(spacing: CGFloat = 5, @ViewBuilder content: () -> some View) -> some View { + VStack(spacing: spacing) { + content() + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(AppConstants.largeItemCornerRadius) + } + + @ViewBuilder func settingRow( + _ label: String, + systemImage: String, + value: String, + color: Color = .primary + ) -> some View { + HStack { + Image(systemName: systemImage) + .foregroundStyle(.secondary) + .frame(width: 30) + Text(label) + Spacer() + Text(value) + .foregroundStyle(color) + } + .padding(12) + } + + @ViewBuilder func settingRow(_ label: String, systemImage: String, value: Bool) -> some View { + HStack { + Image(systemName: systemImage) + .foregroundStyle(.secondary) + .frame(width: 30) + Text(label) + Spacer() + Text(value ? "Yes" : "No") + .foregroundStyle(value ? .green : .red) + } + .padding(12) + } + + @ViewBuilder + func activeUserBox(_ label: String, value: Int) -> some View { + VStack { + Text(abbreviateNumber(value)) + .font(.title3) + .fontWeight(.semibold) + Text(label) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + + } +} diff --git a/Mlem/Views/Shared/Instance/InstanceView.swift b/Mlem/Views/Shared/Instance/InstanceView.swift index 9bb4a89c6..edd7118de 100644 --- a/Mlem/Views/Shared/Instance/InstanceView.swift +++ b/Mlem/Views/Shared/Instance/InstanceView.swift @@ -10,7 +10,7 @@ import Dependencies import SwiftUI enum InstanceViewTab: String, Identifiable, CaseIterable { - case about, administrators, statistics, uptime, safety + case about, administrators, details, uptime, safety var id: Self { self } @@ -29,6 +29,8 @@ struct InstanceView: View { @Dependency(\.errorHandler) var errorHandler @Dependency(\.siteInformation) var siteInformation + @Environment(\.colorScheme) var colorScheme + @Environment(\.navigationPathWithRoutes) private var navigationPath @Environment(\.scrollViewProxy) private var scrollViewProxy @@ -96,7 +98,7 @@ struct InstanceView: View { VStack(spacing: 0) { VStack(spacing: 4) { Divider() - BubblePicker([.about, .administrators], selected: $selectedTab) { tab in + BubblePicker([.about, .administrators, .details], selected: $selectedTab) { tab in Text(tab.label) } Divider() @@ -119,6 +121,21 @@ struct InstanceView: View { } } else { ProgressView() + .padding(.top) + } + case .details: + if instance.userCount != nil { + VStack(spacing: 0) { + InstanceDetailsView(instance: instance) + .padding(.vertical, 16) + .background(Color(uiColor: .systemGroupedBackground)) + if colorScheme == .light { + Divider() + } + } + } else { + ProgressView() + .padding(.top) } default: EmptyView() @@ -162,11 +179,11 @@ struct InstanceView: View { } 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("
" ) { errorDetails = ErrorDetails( title: "KBin Instance", body: "We can't yet display KBin details.", - icon: "point.3.filled.connected.trianglepath.dotted" + icon: Icons.federation ) } else { errorDetails = ErrorDetails(error: APIClientError.decoding(data, error)) diff --git a/Mlem/Views/Tabs/Feeds/Components/CommunityDetailsView.swift b/Mlem/Views/Tabs/Feeds/Components/CommunityDetailsView.swift index a8337bedc..566aa5e2a 100644 --- a/Mlem/Views/Tabs/Feeds/Components/CommunityDetailsView.swift +++ b/Mlem/Views/Tabs/Feeds/Components/CommunityDetailsView.swift @@ -12,56 +12,45 @@ struct CommunityDetailsView: View { var body: some View { VStack(spacing: 16) { - VStack(spacing: 5) { + box { + HStack { + Label(community.creationDate.dateString, systemImage: Icons.cakeDay) + Text("•") + Label(community.creationDate.getRelativeTime(date: Date.now, unitsStyle: .abbreviated), systemImage: Icons.time) + } + .foregroundStyle(.secondary) + .font(.footnote) + } + box { Text("Subscribers") .foregroundStyle(.secondary) Text("\(community.subscriberCount ?? 0)") .fontWeight(.semibold) .font(.title) } - .padding(.vertical) - .frame(maxWidth: .infinity) - .background(Color(uiColor: .secondarySystemGroupedBackground)) - .cornerRadius(AppConstants.largeItemCornerRadius) HStack(spacing: 16) { - VStack(spacing: 5) { - HStack { - Text("Posts") - .foregroundStyle(.secondary) - } - HStack { - Text("\(abbreviateNumber(community.postCount ?? 0))") - .font(.title) - .fontWeight(.semibold) - .foregroundStyle(.pink) - } + box { + Text("Posts") + .foregroundStyle(.secondary) + Text("\(abbreviateNumber(community.postCount ?? 0))") + .font(.title) + .fontWeight(.semibold) + .foregroundStyle(.pink) } - .padding(10) - .frame(maxWidth: .infinity) - .background(Color(uiColor: .secondarySystemGroupedBackground)) - .cornerRadius(AppConstants.largeItemCornerRadius) - VStack(spacing: 5) { - HStack { - Text("Comments") - .foregroundStyle(.secondary) - } - HStack { - Text("\(abbreviateNumber(community.commentCount ?? 0))") - .font(.title) - .fontWeight(.semibold) - .foregroundStyle(.orange) - } + box { + Text("Comments") + .foregroundStyle(.secondary) + Text("\(abbreviateNumber(community.commentCount ?? 0))") + .font(.title) + .fontWeight(.semibold) + .foregroundStyle(.orange) } - .padding(10) - .frame(maxWidth: .infinity) - .background(Color(uiColor: .secondarySystemGroupedBackground)) - .cornerRadius(AppConstants.largeItemCornerRadius) } .frame(maxWidth: .infinity) if let activeUserCount = community.activeUserCount { - VStack(spacing: 8) { + box(spacing: 8) { Text("Active Users") .foregroundStyle(.secondary) HStack(spacing: 16) { @@ -71,15 +60,21 @@ struct CommunityDetailsView: View { activeUserBox("1d", value: activeUserCount.day) } } - .padding(.vertical, 10) - .frame(maxWidth: .infinity) - .background(Color(uiColor: .secondarySystemGroupedBackground)) - .cornerRadius(AppConstants.largeItemCornerRadius) } } .padding(.horizontal, 16) } + @ViewBuilder func box(spacing: CGFloat = 5, @ViewBuilder content: () -> some View) -> some View { + VStack(spacing: spacing) { + content() + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(AppConstants.largeItemCornerRadius) + } + @ViewBuilder func activeUserBox(_ label: String, value: Int) -> some View { VStack { diff --git a/Mlem/Views/Tabs/Profile/UserView+Logic.swift b/Mlem/Views/Tabs/Profile/UserView+Logic.swift index 4773b8778..1e9ae01c5 100644 --- a/Mlem/Views/Tabs/Profile/UserView+Logic.swift +++ b/Mlem/Views/Tabs/Profile/UserView+Logic.swift @@ -20,12 +20,6 @@ extension UserView { 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 { diff --git a/Mlem/Views/Tabs/Profile/UserView.swift b/Mlem/Views/Tabs/Profile/UserView.swift index 5ca806746..ff8a5f6fd 100644 --- a/Mlem/Views/Tabs/Profile/UserView.swift +++ b/Mlem/Views/Tabs/Profile/UserView.swift @@ -93,7 +93,7 @@ struct UserView: View { MarkdownView(text: bio, isNsfw: false, alignment: bioAlignment).padding(AppConstants.postAndCommentSpacing) } HStack { - Label(cakeDayFormatter.string(from: user.creationDate), systemImage: Icons.cakeDay) + Label(user.creationDate.dateString, systemImage: Icons.cakeDay) Text("•") Label(user.creationDate.getRelativeTime(date: Date.now, unitsStyle: .abbreviated), systemImage: Icons.time) if bioAlignment == .leading { @@ -210,49 +210,28 @@ struct UserView: View { if !flairs.isEmpty { VStack(spacing: AppConstants.postAndCommentSpacing) { ForEach(flairs, 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 { + flairBackground(color: flair.color) { + HStack { + switch flair { + case .banned: Image(systemName: Icons.bannedFlair) if let expirationDate = user.banExpirationDate { - Text("Banned Until \(cakeDayFormatter.string(from: expirationDate))") + Text("Banned Until \(expirationDate.dateString)") } 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 { + case .admin: Image(systemName: Icons.adminFlair) let host = user.profileUrl.host() Text("\(host ?? "Instance") Administrator") - } - } - case .moderator: - flairBackground(color: flair.color) { - HStack { + case .moderator: Image(systemName: Icons.moderationFill) Text("\(communityContext?.displayName ?? "Community") Moderator") + default: + Image(systemName: flair.icon) + Text(flair.label) } } - default: - EmptyView() } } } diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Advanced/AdvancedSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Advanced/AdvancedSettingsView.swift index 7f47e6ab8..5586f578a 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Advanced/AdvancedSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Advanced/AdvancedSettingsView.swift @@ -21,6 +21,8 @@ struct AdvancedSettingsView: View { settingName: "Developer Mode", isTicked: $developerMode ) + } footer: { + Text("Shows additional technical information.") } Section {