diff --git a/Revolt.xcodeproj/project.pbxproj b/Revolt.xcodeproj/project.pbxproj index 9a6cd16..df30736 100644 --- a/Revolt.xcodeproj/project.pbxproj +++ b/Revolt.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ 1759C39C2B291E2F006E6BBE /* SystemMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1759C39B2B291E2F006E6BBE /* SystemMessageView.swift */; }; 175E01DE2ADAB3F6004F6431 /* LazyImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175E01DD2ADAB3F6004F6431 /* LazyImage.swift */; }; 175E01E02ADAB554004F6431 /* Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175E01DF2ADAB554004F6431 /* Avatar.swift */; }; + 176FA6E82C9336A900EF73C0 /* ViewInvite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176FA6E72C9336A900EF73C0 /* ViewInvite.swift */; }; 1773C0272C07DC32007B8867 /* Types.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 17DE7DB22BFAEC3F00C99188 /* Types.framework */; platformFilters = (ios, maccatalyst, macos, ); }; 1773C0282C07DC32007B8867 /* Types.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 17DE7DB22BFAEC3F00C99188 /* Types.framework */; platformFilters = (ios, maccatalyst, macos, ); settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 1773C02F2C07DC99007B8867 /* Api.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DE7E0C2BFAFDDD00C99188 /* Api.swift */; }; @@ -88,6 +89,7 @@ 17BF54D12B17B24200178866 /* LanguageSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17BF54D02B17B24200178866 /* LanguageSettings.swift */; }; 17C444582B06CC5400F97E4A /* ForgotPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C444572B06CC5400F97E4A /* ForgotPassword.swift */; }; 17C9A3502B0580D10043A387 /* Welcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C9A34F2B0580D10043A387 /* Welcome.swift */; }; + 17CC0CB42C937D4F005BA3A6 /* ServerBadges.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CC0CB32C937D4F005BA3A6 /* ServerBadges.swift */; }; 17CCCF6E2ADA173B00D78D7A /* ULID in Frameworks */ = {isa = PBXBuildFile; productRef = 17CCCF6D2ADA173B00D78D7A /* ULID */; }; 17CCCF702ADA17B600D78D7A /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CCCF6F2ADA17B600D78D7A /* Utils.swift */; }; 17CE2E142AE709CE0059FB3A /* UserSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CE2E132AE709CE0059FB3A /* UserSheet.swift */; }; @@ -216,6 +218,7 @@ 1759C39B2B291E2F006E6BBE /* SystemMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMessageView.swift; sourceTree = ""; }; 175E01DD2ADAB3F6004F6431 /* LazyImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyImage.swift; sourceTree = ""; }; 175E01DF2ADAB554004F6431 /* Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Avatar.swift; sourceTree = ""; }; + 176FA6E72C9336A900EF73C0 /* ViewInvite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewInvite.swift; sourceTree = ""; }; 1773C03C2C07DD1F007B8867 /* MessageableChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageableChannel.swift; sourceTree = ""; }; 17772C172C30AF83000D1EDA /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 1777DD832ADC24CE003D6C72 /* ReversedSrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReversedSrollView.swift; sourceTree = ""; }; @@ -415,6 +418,7 @@ 17BF54CC2B1785E200178866 /* HomeWelcome.swift */, 17B7128C2B03E9D700CFF61C /* FriendsList.swift */, 17F502532B9BFB1000A3022D /* AddFriend.swift */, + 176FA6E72C9336A900EF73C0 /* ViewInvite.swift */, ); path = Home; sourceTree = ""; @@ -910,6 +914,7 @@ 170C23D52C224B6D0057E399 /* SettingsCommon.swift in Sources */, 170C23D62C224B6D0057E399 /* UserSettings.swift in Sources */, 178BB11B2B02FCDD001143A4 /* Responses.swift in Sources */, + 176FA6E82C9336A900EF73C0 /* ViewInvite.swift in Sources */, 173D698E2C7FF7FC00030E62 /* ChannelSearch.swift in Sources */, 17A589FA2C44781F00B9D85A /* AnyTransition.swift in Sources */, 17BF54CD2B1785E200178866 /* HomeWelcome.swift in Sources */, diff --git a/Revolt/Api/Http.swift b/Revolt/Api/Http.swift index 8cf7158..6171d26 100644 --- a/Revolt/Api/Http.swift +++ b/Revolt/Api/Http.swift @@ -363,4 +363,8 @@ struct HTTPClient { func fetchMutuals(user: String) async -> Result { await req(method: .get, route: "/users/\(user)/mutual") } + + func fetchInvite(code: String) async -> Result { + await req(method: .get, route: "/invites/\(code)") + } } diff --git a/Revolt/Api/Responses.swift b/Revolt/Api/Responses.swift index 24041cc..34995ce 100644 --- a/Revolt/Api/Responses.swift +++ b/Revolt/Api/Responses.swift @@ -84,3 +84,50 @@ struct MutualsResponse: Decodable { var servers: [String] var users: [String] } + +enum InviteInfoResponse { + case server(ServerInfoResponse) + case group(GroupInfoResponse) +} + +extension InviteInfoResponse: Decodable { + enum CodingKeys: String, CodingKey { case type } + enum Tag: String, Codable { case Server, Group } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let singleValueContainer = try decoder.singleValueContainer() + + switch try container.decode(Tag.self, forKey: .type) { + case .Server: + self = .server(try singleValueContainer.decode(ServerInfoResponse.self)) + case .Group: + self = .group(try singleValueContainer.decode(GroupInfoResponse.self)) + + } + } +} + +struct ServerInfoResponse: Decodable { + var code: String + var server_id: String + var server_name: String + var server_icon: File? + var server_banner: File? + var server_flags: ServerFlags? + var channel_id: String + var channel_name: String + var channel_description: String? + var user_name: String + var user_avatar: File? + var member_count: Int +} + +struct GroupInfoResponse: Decodable { + var code: String + var channel_id: String + var channel_name: String + var channel_description: String? + var user_name: String + var user_avatar: File? +} diff --git a/Revolt/Components/Home/ServerChannelScrollView.swift b/Revolt/Components/Home/ServerChannelScrollView.swift index 77698a2..b58c769 100644 --- a/Revolt/Components/Home/ServerChannelScrollView.swift +++ b/Revolt/Components/Home/ServerChannelScrollView.swift @@ -154,24 +154,7 @@ struct ServerChannelScrollView: View { } HStack(alignment: .center, spacing: 8) { - if server.flags?.contains(.offical) == true { - ZStack(alignment: .center) { - Image(systemName: "seal.fill") - .resizable() - .frame(width: 12, height: 12) - .foregroundStyle(.white) - - Image("monochrome") - .resizable() - .frame(width: 10, height: 10) - .colorInvert() - } - } else if server.flags?.contains(.verified) == true { - Image(systemName: "checkmark.seal.fill") - .resizable() - .foregroundStyle(.black, .white) - .frame(width: 12, height: 12) - } + ServerBadges(value: server.flags) Text(server.name) .fontWeight(.medium) diff --git a/Revolt/Components/ServerBadges.swift b/Revolt/Components/ServerBadges.swift new file mode 100644 index 0000000..d2bdb91 --- /dev/null +++ b/Revolt/Components/ServerBadges.swift @@ -0,0 +1,35 @@ +// +// ServerBadges.swift +// Revolt +// +// Created by Angelo on 12/09/2024. +// + +import Foundation +import SwiftUI +import Types + +struct ServerBadges: View { + var value: ServerFlags? + + var body: some View { + if value?.contains(.offical) == true { + ZStack(alignment: .center) { + Image(systemName: "seal.fill") + .resizable() + .frame(width: 12, height: 12) + .foregroundStyle(.white) + + Image("monochrome") + .resizable() + .frame(width: 10, height: 10) + .colorInvert() + } + } else if value?.contains(.verified) == true { + Image(systemName: "checkmark.seal.fill") + .resizable() + .foregroundStyle(.black, .white) + .frame(width: 12, height: 12) + } + } +} diff --git a/Revolt/Components/ServerIcon.swift b/Revolt/Components/ServerIcon.swift index fe80669..001bc2d 100644 --- a/Revolt/Components/ServerIcon.swift +++ b/Revolt/Components/ServerIcon.swift @@ -22,20 +22,32 @@ struct ServerIcon: View { if let icon = server.icon { LazyImage(source: .file(icon), height: height, width: height, clipTo: clipTo) } else { - ZStack(alignment: .center) { - let firstChar = server.name.first ?? "?" - - clipTo - .fill(viewState.theme.background3) // TODO: background3 - .frame(width: width, height: height) - - Text(verbatim: "\(firstChar)") - .bold() - } + FallbackServerIcon(name: server.name, width: width, height: height, clipTo: clipTo) } } } +struct FallbackServerIcon: View { + @EnvironmentObject var viewState: ViewState + + var name: String + var width: CGFloat? + var height: CGFloat? + var clipTo: S + + var body: some View { + ZStack(alignment: .center) { + let firstChar = name.first ?? "?" + + clipTo + .fill(viewState.theme.background3) + .frame(width: width, height: height) + + Text(verbatim: "\(firstChar)") + .bold() + } + } +} struct ServerListIcon: View { @EnvironmentObject var viewState: ViewState diff --git a/Revolt/Components/Sheets/AddServerSheet.swift b/Revolt/Components/Sheets/AddServerSheet.swift index 25b185b..da5c72c 100644 --- a/Revolt/Components/Sheets/AddServerSheet.swift +++ b/Revolt/Components/Sheets/AddServerSheet.swift @@ -70,14 +70,23 @@ struct JoinServerAlert: View { @State var text: String = "" + func parseInvite() -> String? { + if let match = text.wholeMatch(of: /(?:(?:https?:\/\/)?rvlt\.gg\/)?(\w+)/) { + return String(match.output.1) + } else { + return nil + } + } + var body: some View { TextField("Invite code or link", text: $text) Button("Join") { + Task { - let join = try! await viewState.http.joinServer(code: text).get() - viewState.servers[join.server.id] = join.server - viewState.selectServer(withId: join.server.id) + if let invite_code = parseInvite(), (try! await viewState.http.fetchInvite(code: invite_code).get()) != nil { + viewState.path.append(NavigationDestination.invite(invite_code)) + } } } diff --git a/Revolt/Components/Sheets/ServerInfoSheet.swift b/Revolt/Components/Sheets/ServerInfoSheet.swift index 559e2f2..45240d0 100644 --- a/Revolt/Components/Sheets/ServerInfoSheet.swift +++ b/Revolt/Components/Sheets/ServerInfoSheet.swift @@ -29,10 +29,15 @@ struct ServerInfoSheet: View { ServerIcon(server: server, height: 64, width: 64, clipTo: Rectangle()) .addBorder(.black, width: 2, cornerRadius: 8) - Text(server.name) - .bold() - .padding(.horizontal, 8) - .padding(.bottom, 8) + HStack(alignment: .center, spacing: 8) { + ServerBadges(value: server.flags) + + Text(server.name) + .bold() + } + .padding(.horizontal, 8) + .padding(.bottom, 8) + } .padding(.top, server.banner == nil ? 8 : 0) .padding(.horizontal, 8) diff --git a/Revolt/Pages/Home/ViewInvite.swift b/Revolt/Pages/Home/ViewInvite.swift new file mode 100644 index 0000000..37291ab --- /dev/null +++ b/Revolt/Pages/Home/ViewInvite.swift @@ -0,0 +1,113 @@ +// +// ViewInvite.swift +// Revolt +// +// Created by Angelo on 12/09/2024. +// + +import Foundation +import SwiftUI + +struct ViewInvite: View { + @EnvironmentObject var viewState: ViewState + + var code: String + + @State var info: InviteInfoResponse?? = nil + + var body: some View { + ZStack { + switch info { + case .none: + LoadingSpinnerView(frameSize: CGSize(width: 32, height: 32), isActionComplete: .constant(false)) + case .some(.none): + Text("Invalid invite") + case .group(let groupInfo): + Text("Group TODO") + case .server(let serverInfo): + if let banner = serverInfo.server_banner { + LazyImage(source: .file(banner), clipTo: Rectangle()) + } + + VStack(spacing: 16) { + Group { + if let server_icon = serverInfo.server_icon { + LazyImage(source: .file(server_icon), clipTo: Circle()) + } else { + FallbackServerIcon(name: serverInfo.server_name, clipTo: Circle()) + } + } + .frame(width: 48, height: 48) + + HStack(alignment: .center, spacing: 8) { + ServerBadges(value: serverInfo.server_flags) + + Text(verbatim: serverInfo.server_name) + .fontWeight(.medium) + } + + HStack { + Text("#\(serverInfo.channel_name)") + + Circle() + .frame(width: 4, height: 4) + + Text("\(serverInfo.member_count) users") + } + .foregroundStyle(viewState.theme.foreground2) + + HStack { + Text("Invited by") + + HStack { + if let avatar = serverInfo.user_avatar { + LazyImage(source: .file(avatar), clipTo: Circle()) + .frame(width: 24, height: 24) + } + + Text(verbatim: serverInfo.user_name) + } + } + + Button { + Task { + let join = try! await viewState.http.joinServer(code: code).get() + viewState.servers[join.server.id] = join.server + + for channel in join.channels { + viewState.channels[channel.id] = channel + viewState.channelMessages[channel.id] = [] + } + + viewState.selectChannel(inServer: serverInfo.server_id, withId: serverInfo.channel_id) + } + } label: { + Text("Accept Invite") + .padding(.vertical, 8) + .padding(.horizontal, 16) + } + .background(viewState.theme.background2) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .padding(16) + .background(viewState.theme.background.opacity(0.95)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + .toolbarBackground(viewState.theme.topBar, for: .automatic) + .toolbar { + ToolbarItem(placement: .principal) { + HStack { + Text("Join Invite") + } + } + } + .task { + if let info = try? await viewState.http.fetchInvite(code: code).get() { + self.info = info + } else { + self.info = .some(.none) + } + } + } +} diff --git a/Revolt/RevoltApp.swift b/Revolt/RevoltApp.swift index 4104960..1216f25 100644 --- a/Revolt/RevoltApp.swift +++ b/Revolt/RevoltApp.swift @@ -226,6 +226,8 @@ struct InnerApp: View { case .channel_search(let id): let channel = Binding($viewState.channels[id])! ChannelSearch(channel: channel) + case .invite(let code): + ViewInvite(code: code) } } .sheet(item: $viewState.currentUserSheet) { (v) in diff --git a/Revolt/ViewState.swift b/Revolt/ViewState.swift index 1ffceaa..85ce9d7 100644 --- a/Revolt/ViewState.swift +++ b/Revolt/ViewState.swift @@ -116,6 +116,7 @@ enum NavigationDestination: Hashable, Codable { case create_group([String]) case create_server case channel_search(String) + case invite(String) } struct UserMaybeMember: Identifiable {