diff --git a/Revolt.xcodeproj/project.pbxproj b/Revolt.xcodeproj/project.pbxproj index 84d9b3e..30e037b 100644 --- a/Revolt.xcodeproj/project.pbxproj +++ b/Revolt.xcodeproj/project.pbxproj @@ -62,6 +62,8 @@ 1777DD892ADC3C31003D6C72 /* Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1777DD882ADC3C31003D6C72 /* Markdown.swift */; }; 1777DD8E2ADC4336003D6C72 /* RemoteImageTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1777DD8D2ADC4336003D6C72 /* RemoteImageTextAttachment.swift */; }; 1782F5E62B08F60B00759D40 /* Discovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1782F5E52B08F60B00759D40 /* Discovery.swift */; }; + 17863A592C8094840051A52C /* Tile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17863A582C8094840051A52C /* Tile.swift */; }; + 17863A5C2C8098AF0051A52C /* ExyteGrid in Frameworks */ = {isa = PBXBuildFile; productRef = 17863A5B2C8098AF0051A52C /* ExyteGrid */; }; 1788F96E2C3CA19800A385A8 /* MessageEmbed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1788F96D2C3CA19400A385A8 /* MessageEmbed.swift */; }; 1788F9702C3CA3A600A385A8 /* Embed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1788F96F2C3CA3A300A385A8 /* Embed.swift */; }; 178BB10C2B02D84C001143A4 /* HCaptcha in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, maccatalyst, ); productRef = 178BB10B2B02D84C001143A4 /* HCaptcha */; }; @@ -217,6 +219,7 @@ 1777DD882ADC3C31003D6C72 /* Markdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Markdown.swift; sourceTree = ""; }; 1777DD8D2ADC4336003D6C72 /* RemoteImageTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImageTextAttachment.swift; sourceTree = ""; }; 1782F5E52B08F60B00759D40 /* Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Discovery.swift; sourceTree = ""; }; + 17863A582C8094840051A52C /* Tile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tile.swift; sourceTree = ""; }; 1788F96D2C3CA19400A385A8 /* MessageEmbed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageEmbed.swift; sourceTree = ""; }; 1788F96F2C3CA3A300A385A8 /* Embed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Embed.swift; sourceTree = ""; }; 178BB10F2B02D89A001143A4 /* HCaptchaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HCaptchaView.swift; sourceTree = ""; }; @@ -311,6 +314,7 @@ 178BB10C2B02D84C001143A4 /* HCaptcha in Frameworks */, DA99EC5329D6148200419FDA /* Alamofire in Frameworks */, 178BB10E2B02D84C001143A4 /* HCaptcha_RxSwift in Frameworks */, + 17863A5C2C8098AF0051A52C /* ExyteGrid in Frameworks */, 174DA9E82B9E4D70001BC330 /* Parsing in Frameworks */, 17CCCF6E2ADA173B00D78D7A /* ULID in Frameworks */, 1773C0272C07DC32007B8867 /* Types.framework in Frameworks */, @@ -448,6 +452,7 @@ 1718F3CF2B0BCAA50018E524 /* UnreadCounter.swift */, 172754C82B399092002223FE /* Contents.swift */, 17BADE002B7019270021BB62 /* EmojiPicker.swift */, + 17863A582C8094840051A52C /* Tile.swift */, ); path = Components; sourceTree = ""; @@ -738,6 +743,7 @@ 03268FCCFC7D4D1F8B6E9F6F /* Sentry */, 1746CF592B83C6750051FD47 /* CodableWrapper */, 174DA9E72B9E4D70001BC330 /* Parsing */, + 17863A5B2C8098AF0051A52C /* ExyteGrid */, ); productName = Revolt; productReference = D49B704F29C4D3FE009494A5 /* Revolt.app */; @@ -790,6 +796,7 @@ 1746CF582B83C6750051FD47 /* XCRemoteSwiftPackageReference "CodableWrapper" */, 174DA9E62B9E4D70001BC330 /* XCRemoteSwiftPackageReference "swift-parsing" */, 1757A26B2BB7444D007EE8B9 /* XCRemoteSwiftPackageReference "client-sdk-swift" */, + 17863A5A2C80988C0051A52C /* XCRemoteSwiftPackageReference "Grid" */, ); productRefGroup = D49B705029C4D3FE009494A5 /* Products */; projectDirPath = ""; @@ -933,6 +940,7 @@ 1759C39A2B291A75006E6BBE /* MessageContentsView.swift in Sources */, 17E019D12AF14EC000AB4663 /* ServerIcon.swift in Sources */, 17F8B7092C7983730065F1DE /* CreateServer.swift in Sources */, + 17863A592C8094840051A52C /* Tile.swift in Sources */, 172F2D012C22ED4D00948C00 /* IteratorProtocol.swift in Sources */, 1718F3D02B0BCAA50018E524 /* UnreadCounter.swift in Sources */, 17F502562B9BFB2800A3022D /* CreateGroup.swift in Sources */, @@ -1518,6 +1526,14 @@ minimumVersion = 1.2.0; }; }; + 17863A5A2C80988C0051A52C /* XCRemoteSwiftPackageReference "Grid" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/exyte/Grid"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.5.0; + }; + }; 178BB10A2B02D84C001143A4 /* XCRemoteSwiftPackageReference "HCaptcha-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/hCaptcha/HCaptcha-ios-sdk"; @@ -1602,6 +1618,11 @@ package = 175997172B2FB90600C39CF6 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */; productName = Flow; }; + 17863A5B2C8098AF0051A52C /* ExyteGrid */ = { + isa = XCSwiftPackageProductDependency; + package = 17863A5A2C80988C0051A52C /* XCRemoteSwiftPackageReference "Grid" */; + productName = ExyteGrid; + }; 178BB10B2B02D84C001143A4 /* HCaptcha */ = { isa = XCSwiftPackageProductDependency; package = 178BB10A2B02D84C001143A4 /* XCRemoteSwiftPackageReference "HCaptcha-ios-sdk" */; diff --git a/Revolt/Components/Sheets/UserSheet.swift b/Revolt/Components/Sheets/UserSheet.swift index 4f6ea90..d8cf258 100644 --- a/Revolt/Components/Sheets/UserSheet.swift +++ b/Revolt/Components/Sheets/UserSheet.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI import Flow import Types +import ExyteGrid enum Badges: Int, CaseIterable { case developer = 1 @@ -31,92 +32,29 @@ struct UserSheetHeader: View { var profile: Profile var body: some View { - ZStack { + ZStack(alignment: .bottomLeading) { if let banner = profile.background { - ZStack { - LazyImage(source: .file(banner), height: 150, clipTo: RoundedRectangle(cornerRadius: 10)) - - LinearGradient(colors: [.clear, .black], startPoint: .top, endPoint: .bottom) - .frame(height: 150) - .clipShape(RoundedRectangle(cornerRadius: 10)) - - } + LazyImage(source: .file(banner), height: 115, clipTo: RoundedRectangle(cornerRadius: 12)) } - VStack(alignment: .leading) { - Spacer() - .frame(maxHeight: 30) + HStack(alignment: .center, spacing: 16) { + Avatar(user: user, width: 48, height: 48, withPresence: true) - HStack(alignment: .center) { - Avatar(user: user, width: 48, height: 48, withPresence: true) - - VStack(alignment: .leading) { - if let display_name = user.display_name { - Text(display_name) - .foregroundStyle(.white) - .bold() - } - - Text("\(user.username)") + VStack(alignment: .leading) { + if let display_name = user.display_name { + Text(display_name) .foregroundStyle(.white) - + Text("#\(user.discriminator)") - .foregroundStyle(.gray) + .bold() } - Spacer() - .frame(maxHeight: 20) - - switch user.relationship ?? .None { - case .Blocked: - EmptyView() // TODO: unblock - case .BlockedOther, .User: - EmptyView() - case .Friend: - Button { - Task { - await viewState.openDm(with: user.id) - } - } label: { - Image(systemName: "message.fill") - .resizable() - .frame(width: 32, height: 32) - } - case .Incoming, .None: - Button { - Task { - await viewState.http.sendFriendRequest(username: user.username) - } - } label: { - Image(systemName: "person.badge.plus") - .resizable() - .frame(width: 32, height: 32) - } - case .Outgoing: - Button { - Task { - await viewState.http.removeFriend(user: user.id) - } - } label: { - Image(systemName: "person.badge.clock") - .resizable() - .frame(width: 32, height: 32) - } - } - } - - if let badges = user.badges, badges != 0 { - HStack { - ForEach(Badges.allCases, id: \.self) { value in - Badge(badges: badges, filename: String(describing: value), value: value.rawValue) - } - } - .padding(8) - .background(.ultraThinMaterial.opacity(0.4)) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .padding(.vertical) + Text("\(user.username)") + .foregroundStyle(viewState.theme.foreground) + + Text("#\(user.discriminator)") + .foregroundStyle(viewState.theme.foreground2) } } - .padding(8) + .padding(.leading, 16) + .padding(.bottom, 16) } } } @@ -126,66 +64,200 @@ struct UserSheet: View { var user: User var member: Member? + @State var profile: Profile? - + @State var owner: User = .init(id: String(repeating: "0", count: 26), username: "Unknown", discriminator: "0000") + + func getRoleColour(role: Role) -> AnyShapeStyle { + if let colour = role.colour { + return parseCSSColor(currentTheme: viewState.theme, input: colour) + } else { + return AnyShapeStyle(viewState.theme.foreground) + } + } + var body: some View { - Spacer() - .frame(maxHeight: 10) - ScrollView { - Group { - VStack(alignment: .leading) { - if let profile = profile { - UserSheetHeader(user: user, member: member, profile: profile) - - Group { - if let member = member { - let server = viewState.servers[member.id.server]! - - if let roles = member.roles { - Text("Roles") - .font(.caption) + VStack(alignment: .leading, spacing: 8) { + if let profile = profile { + + Grid(tracks: 2, flow: .rows, spacing: 12) { + UserSheetHeader(user: user, member: member, profile: profile) + .gridSpan(column: 2) - VStack(alignment: .leading) { - HFlow { - ForEach(roles, id: \.self) { roleId in - let role = server.roles![roleId]! - - let colour = role.colour != nil ? ThemeColor(hex: role.colour!) : viewState.theme.foreground - - Text(role.name) - .font(.caption) - .foregroundStyle(colour) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(RoundedRectangle(cornerRadius: 50).stroke(colour)) - } - } + if let member = member, + let server = viewState.servers[member.id.server], + let roles = member.roles, !roles.isEmpty + { + Tile("Roles") { + ScrollView { + ForEach(roles, id: \.self) { roleId in + let role = server.roles![roleId]! + + HStack { + Text(role.name) + + Spacer() + + Circle() + .foregroundStyle(getRoleColour(role: role)) + .frame(width: 16, height: 16) } } } + } + } + + Tile("Joined") { + VStack(alignment: .leading) { + Text(createdAt(id: user.id), style: .date) + Text("Revolt") + .bold() + } + //.frame(maxWidth: .infinity) + + if let member { + let server = viewState.servers[member.id.server]! + let formatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions.insert(.withFractionalSeconds) + return formatter + }() - if let bio = profile.content { - Text("Bio") - .font(.caption) + VStack(alignment: .leading) { + Text(formatter.date(from: member.joined_at)!, style: .date) // TODO + Text(verbatim: server.name) + } + //.frame(maxWidth: .infinity) + } + } + + if let badges = user.badges { + Tile("Badges") { + HFlow { + ForEach(Badges.allCases, id: \.self) { value in + Badge(badges: badges, filename: String(describing: value), value: value.rawValue) + } + } + } + } + + if let bot = user.bot { + Tile("Owner") { + HStack(spacing: 12) { + Avatar(user: owner) + Text(owner.display_name ?? owner.username) + } + } + .task { + if let user = viewState.users[bot.owner] { + owner = user + } else { + Task { + if case .success(let user) = await viewState.http.fetchUser(user: bot.owner) { + owner = user + } + } + } + } + } + + if let bio = profile.content { + Tile("Bio") { + ScrollView { Contents(text: .constant(bio), fontSize: 17) } - - Spacer() } - .padding(4) - - } else { - Text("Loading...") + .gridSpan(column: 2) } + + Group { + switch user.relationship ?? .None { + case .User: + Button { + viewState.path.append(NavigationDestination.settings) + } label: { + HStack { + Spacer() + + Text("Edit profile") + + Spacer() + } + } + .padding(8) + .background(viewState.theme.accent, in: RoundedRectangle(cornerRadius: 50)) + + case .Blocked: + EmptyView() // TODO: unblock + case .BlockedOther: + EmptyView() + case .Friend: + Button { + Task { + await viewState.openDm(with: user.id) + } + } label: { + HStack { + Spacer() + + Text("Send Message") + + Spacer() + } + } + .padding(8) + .background(viewState.theme.accent, in: RoundedRectangle(cornerRadius: 50)) + + case .Incoming, .None: + Button { + Task { + await viewState.http.sendFriendRequest(username: user.username) + } + } label: { + HStack { + Spacer() + + Text("Add Friend") + + Spacer() + } + } + .padding(8) + .background(viewState.theme.accent, in: RoundedRectangle(cornerRadius: 50)) + + case .Outgoing: + Button { + Task { + await viewState.http.removeFriend(user: user.id) + } + } label: { + HStack { + Spacer() + + Text("Cancel Friend Request") + + Spacer() + } + } + .padding(8) + .background(viewState.theme.accent, in: RoundedRectangle(cornerRadius: 50)) + } + } + .gridSpan(column: 2) } + .gridContentMode(.scroll) + .gridFlow(.rows) + .gridPacking(.sparse) + .gridCommonItemsAlignment(.topLeading) + } else { + Text("Loading...") } } - .scrollBounceBehavior(.basedOnSize, axes: .vertical) .frame(maxWidth: .infinity) - .padding(.horizontal, 16) + //.padding(.horizontal, 16) + .padding(.top, 16) .background(viewState.theme.background.color) - .presentationDetents([.fraction(0.4), .large]) .presentationBackground(viewState.theme.background) .task { if let profile = user.profile { @@ -208,6 +280,7 @@ struct Badge: View { if badges & (value << 0) != 0 { Image(filename) .resizable() + .scaledToFit() .frame(width: 24, height: 24) } } diff --git a/Revolt/Components/Tile.swift b/Revolt/Components/Tile.swift new file mode 100644 index 0000000..637778c --- /dev/null +++ b/Revolt/Components/Tile.swift @@ -0,0 +1,72 @@ +// +// TileGrid.swift +// Revolt +// +// Created by Angelo on 29/08/2024. +// + +import Foundation +import SwiftUI +import ExyteGrid + +struct Tile: View { + @EnvironmentObject var viewState: ViewState + + var title: String + var content: () -> Body + + @State var showPopout: Bool = false + + init( + _ title: String, + @ViewBuilder content: @escaping () -> Body + ) { + self.title = title + self.content = content + } + + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .bold() + .font(.title) + + HStack { + VStack(alignment: .leading) { + content() + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + .frame(height: 160) + .padding(.horizontal, 16) + .padding(.top, 8) + .background(viewState.theme.background2, in: RoundedRectangle(cornerRadius: 12)) + .onTapGesture { + showPopout.toggle() + } + .sheet(isPresented: $showPopout) { + ScrollView { + VStack(alignment: .leading) { + Text(title) + .bold() + .font(.title) + + Group { + content() + } + } + } + .padding(.horizontal, 16) + .presentationDetents([.medium]) + .presentationBackground(viewState.theme.background) + } + } +}