diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 20eb8596..679230ba 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -28,16 +28,18 @@ 796298992AEBBA77000AA957 /* MFAFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 796298982AEBBA77000AA957 /* MFAFlow.swift */; }; 7962989D2AEBC6F9000AA957 /* SVGView in Frameworks */ = {isa = PBXBuildFile; productRef = 7962989C2AEBC6F9000AA957 /* SVGView */; }; 79719ECE2ADF26C400737804 /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = 79719ECD2ADF26C400737804 /* Supabase */; }; - 797D664A2B46A1D8007592ED /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797D66492B46A1D8007592ED /* Store.swift */; }; + 797D664A2B46A1D8007592ED /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797D66492B46A1D8007592ED /* Dependencies.swift */; }; 7993B8A92B3C673A009B610B /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7993B8A82B3C673A009B610B /* AuthView.swift */; }; 7993B8AB2B3C67E0009B610B /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7993B8AA2B3C67E0009B610B /* Toast.swift */; }; 79AF047F2B2CE207008761AD /* AuthWithEmailAndPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79AF047E2B2CE207008761AD /* AuthWithEmailAndPassword.swift */; }; 79AF04812B2CE261008761AD /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79AF04802B2CE261008761AD /* AuthView.swift */; }; 79AF04842B2CE408008761AD /* AuthWithMagicLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79AF04832B2CE408008761AD /* AuthWithMagicLink.swift */; }; 79AF04862B2CE586008761AD /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79AF04852B2CE586008761AD /* Debug.swift */; }; + 79B8F4242B5FED7C0000E839 /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 79B8F4232B5FED7C0000E839 /* IdentifiedCollections */; }; + 79B8F4262B602F640000E839 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B8F4252B602F640000E839 /* Logger.swift */; }; 79BD76772B59C3E300CA3D68 /* UserStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79BD76762B59C3E300CA3D68 /* UserStore.swift */; }; - 79BD76792B59C53900CA3D68 /* ChannelsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79BD76782B59C53900CA3D68 /* ChannelsViewModel.swift */; }; - 79BD767B2B59C61300CA3D68 /* MessagesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79BD767A2B59C61300CA3D68 /* MessagesViewModel.swift */; }; + 79BD76792B59C53900CA3D68 /* ChannelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79BD76782B59C53900CA3D68 /* ChannelStore.swift */; }; + 79BD767B2B59C61300CA3D68 /* MessageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79BD767A2B59C61300CA3D68 /* MessageStore.swift */; }; 79D884CA2B3C18830009EA4A /* SlackCloneApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D884C92B3C18830009EA4A /* SlackCloneApp.swift */; }; 79D884CC2B3C18830009EA4A /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D884CB2B3C18830009EA4A /* AppView.swift */; }; 79D884CE2B3C18840009EA4A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 79D884CD2B3C18840009EA4A /* Assets.xcassets */; }; @@ -80,7 +82,7 @@ 795640692955AFBD0088A06F /* ErrorText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorText.swift; sourceTree = ""; }; 796298982AEBBA77000AA957 /* MFAFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MFAFlow.swift; sourceTree = ""; }; 7962989A2AEBBD9F000AA957 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - 797D66492B46A1D8007592ED /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; + 797D66492B46A1D8007592ED /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; 7993B8A82B3C673A009B610B /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; 7993B8AA2B3C67E0009B610B /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = ""; }; 7993B8AC2B3C97B6009B610B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -88,9 +90,10 @@ 79AF04802B2CE261008761AD /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; 79AF04832B2CE408008761AD /* AuthWithMagicLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthWithMagicLink.swift; sourceTree = ""; }; 79AF04852B2CE586008761AD /* Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debug.swift; sourceTree = ""; }; + 79B8F4252B602F640000E839 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; 79BD76762B59C3E300CA3D68 /* UserStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserStore.swift; sourceTree = ""; }; - 79BD76782B59C53900CA3D68 /* ChannelsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelsViewModel.swift; sourceTree = ""; }; - 79BD767A2B59C61300CA3D68 /* MessagesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesViewModel.swift; sourceTree = ""; }; + 79BD76782B59C53900CA3D68 /* ChannelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelStore.swift; sourceTree = ""; }; + 79BD767A2B59C61300CA3D68 /* MessageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageStore.swift; sourceTree = ""; }; 79D884C72B3C18830009EA4A /* SlackClone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SlackClone.app; sourceTree = BUILT_PRODUCTS_DIR; }; 79D884C92B3C18830009EA4A /* SlackCloneApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlackCloneApp.swift; sourceTree = ""; }; 79D884CB2B3C18830009EA4A /* AppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = ""; }; @@ -132,6 +135,7 @@ buildActionMask = 2147483647; files = ( 79D884D92B3C18E90009EA4A /* Supabase in Frameworks */, + 79B8F4242B5FED7C0000E839 /* IdentifiedCollections in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -234,10 +238,11 @@ 79D884DC2B3C19320009EA4A /* MessagesView.swift */, 7993B8A82B3C673A009B610B /* AuthView.swift */, 7993B8AA2B3C67E0009B610B /* Toast.swift */, - 797D66492B46A1D8007592ED /* Store.swift */, + 797D66492B46A1D8007592ED /* Dependencies.swift */, 79BD76762B59C3E300CA3D68 /* UserStore.swift */, - 79BD76782B59C53900CA3D68 /* ChannelsViewModel.swift */, - 79BD767A2B59C61300CA3D68 /* MessagesViewModel.swift */, + 79BD76782B59C53900CA3D68 /* ChannelStore.swift */, + 79BD767A2B59C61300CA3D68 /* MessageStore.swift */, + 79B8F4252B602F640000E839 /* Logger.swift */, ); path = SlackClone; sourceTree = ""; @@ -318,6 +323,7 @@ name = SlackClone; packageProductDependencies = ( 79D884D82B3C18E90009EA4A /* Supabase */, + 79B8F4232B5FED7C0000E839 /* IdentifiedCollections */, ); productName = SlackClone; productReference = 79D884C72B3C18830009EA4A /* SlackClone.app */; @@ -453,10 +459,11 @@ 7993B8A92B3C673A009B610B /* AuthView.swift in Sources */, 7993B8AB2B3C67E0009B610B /* Toast.swift in Sources */, 79D884DD2B3C19320009EA4A /* MessagesView.swift in Sources */, - 79BD76792B59C53900CA3D68 /* ChannelsViewModel.swift in Sources */, - 797D664A2B46A1D8007592ED /* Store.swift in Sources */, + 79BD76792B59C53900CA3D68 /* ChannelStore.swift in Sources */, + 797D664A2B46A1D8007592ED /* Dependencies.swift in Sources */, 79D884DB2B3C191F0009EA4A /* ChannelListView.swift in Sources */, - 79BD767B2B59C61300CA3D68 /* MessagesViewModel.swift in Sources */, + 79BD767B2B59C61300CA3D68 /* MessageStore.swift in Sources */, + 79B8F4262B602F640000E839 /* Logger.swift in Sources */, 79D884CC2B3C18830009EA4A /* AppView.swift in Sources */, 79BD76772B59C3E300CA3D68 /* UserStore.swift in Sources */, 79D884D72B3C18DB0009EA4A /* Supabase.swift in Sources */, @@ -911,6 +918,11 @@ isa = XCSwiftPackageProductDependency; productName = Supabase; }; + 79B8F4232B5FED7C0000E839 /* IdentifiedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = 7956406E2955B5190088A06F /* XCRemoteSwiftPackageReference "swift-identified-collections" */; + productName = IdentifiedCollections; + }; 79D884D82B3C18E90009EA4A /* Supabase */ = { isa = XCSwiftPackageProductDependency; productName = Supabase; diff --git a/Examples/SlackClone/ChannelListView.swift b/Examples/SlackClone/ChannelListView.swift index ee9a6120..627f71c4 100644 --- a/Examples/SlackClone/ChannelListView.swift +++ b/Examples/SlackClone/ChannelListView.swift @@ -9,14 +9,25 @@ import SwiftUI @MainActor struct ChannelListView: View { - let store = Store.shared.channel + @Bindable var store = Dependencies.shared.channel @Binding var channel: Channel? + @State private var addChannelPresented = false + @State private var newChannelName = "" + var body: some View { List(store.channels, selection: $channel) { channel in NavigationLink(channel.slug, value: channel) } .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add Channel") { + addChannelPresented = true + } + .popover(isPresented: $addChannelPresented) { + addChannelView + } + } ToolbarItem { Button("Log out") { Task { @@ -25,5 +36,26 @@ struct ChannelListView: View { } } } + .toast(state: $store.toast) + } + + private var addChannelView: some View { + Form { + Section { + TextField("New channel name", text: $newChannelName) + } + + Section { + Button("Add") { + Task { + await store.addChannel(newChannelName) + addChannelPresented = false + } + } + } + } + #if os(macOS) + .padding() + #endif } } diff --git a/Examples/SlackClone/ChannelsViewModel.swift b/Examples/SlackClone/ChannelStore.swift similarity index 59% rename from Examples/SlackClone/ChannelsViewModel.swift rename to Examples/SlackClone/ChannelStore.swift index 12945c52..62709539 100644 --- a/Examples/SlackClone/ChannelsViewModel.swift +++ b/Examples/SlackClone/ChannelStore.swift @@ -1,5 +1,5 @@ // -// ChannelsViewModel.swift +// ChannelStore.swift // SlackClone // // Created by Guilherme Souza on 18/01/24. @@ -8,20 +8,19 @@ import Foundation import Supabase -protocol ChannelsStore: AnyObject { - func fetchChannel(id: Channel.ID) async throws -> Channel -} - @MainActor @Observable -final class ChannelsViewModel: ChannelsStore { +final class ChannelStore { + static let shared = ChannelStore() + private(set) var channels: [Channel] = [] + var toast: ToastState? - weak var messages: MessagesStore! + var messages: MessageStore { Dependencies.shared.messages } - init() { + private init() { Task { - channels = try await fetchChannels() + channels = await fetchChannels() let channel = await supabase.realtimeV2.channel("public:channels") @@ -44,6 +43,20 @@ final class ChannelsViewModel: ChannelsStore { } } + func addChannel(_ name: String) async { + do { + let userId = try await supabase.auth.session.user.id + let channel = AddChannel(slug: name, createdBy: userId) + try await supabase.database + .from("channels") + .insert(channel) + .execute() + } catch { + dump(error) + toast = .init(status: .error, title: "Error", description: error.localizedDescription) + } + } + func fetchChannel(id: Channel.ID) async throws -> Channel { if let channel = channels.first(where: { $0.id == id }) { return channel @@ -65,6 +78,7 @@ final class ChannelsViewModel: ChannelsStore { channels.append(channel) } catch { dump(error) + toast = .init(status: .error, title: "Error", description: error.localizedDescription) } } @@ -74,7 +88,13 @@ final class ChannelsViewModel: ChannelsStore { messages.removeMessages(for: id) } - private func fetchChannels() async throws -> [Channel] { - try await supabase.database.from("channels").select().execute().value + private func fetchChannels() async -> [Channel] { + do { + return try await supabase.database.from("channels").select().execute().value + } catch { + dump(error) + toast = .init(status: .error, title: "Error", description: error.localizedDescription) + return [] + } } } diff --git a/Examples/SlackClone/Dependencies.swift b/Examples/SlackClone/Dependencies.swift new file mode 100644 index 00000000..12f34bcb --- /dev/null +++ b/Examples/SlackClone/Dependencies.swift @@ -0,0 +1,52 @@ +// +// Dependencies.swift +// SlackClone +// +// Created by Guilherme Souza on 04/01/24. +// + +import Foundation +import Supabase + +class Dependencies { + static let shared = Dependencies() + + let channel = ChannelStore.shared + let users = UserStore.shared + let messages = MessageStore.shared +} + +struct User: Codable, Identifiable, Hashable { + var id: UUID + var username: String +} + +struct AddChannel: Encodable { + var slug: String + var createdBy: UUID +} + +struct Channel: Identifiable, Codable, Hashable { + var id: Int + var slug: String + var insertedAt: Date +} + +struct Message: Identifiable, Codable, Hashable { + var id: Int + var insertedAt: Date + var message: String + var user: User + var channel: Channel +} + +struct NewMessage: Codable { + var message: String + var userId: UUID + let channelId: Int +} + +struct UserPresence: Codable, Hashable { + var userId: UUID + var onlineAt: Date +} diff --git a/Examples/SlackClone/Logger.swift b/Examples/SlackClone/Logger.swift new file mode 100644 index 00000000..0c6cd627 --- /dev/null +++ b/Examples/SlackClone/Logger.swift @@ -0,0 +1,39 @@ +// +// Logger.swift +// SlackClone +// +// Created by Guilherme Souza on 23/01/24. +// + +import Foundation +import OSLog +import Supabase + +extension Logger { + static let main = Self(subsystem: "com.supabase.SlackClone", category: "app") +} + +final class SupabaseLoggerImpl: SupabaseLogger, @unchecked Sendable { + private let lock = NSLock() + private var loggers: [String: Logger] = [:] + + func log(message: SupabaseLogMessage) { + lock.withLock { + if loggers[message.system] == nil { + loggers[message.system] = Logger( + subsystem: "com.supabase.SlackClone.supabase-swift", + category: message.system + ) + } + + let logger = loggers[message.system]! + + switch message.level { + case .debug: logger.debug("\(message)") + case .error: logger.error("\(message)") + case .verbose: logger.info("\(message)") + case .warning: logger.notice("\(message)") + } + } + } +} diff --git a/Examples/SlackClone/MessageStore.swift b/Examples/SlackClone/MessageStore.swift new file mode 100644 index 00000000..66338d2e --- /dev/null +++ b/Examples/SlackClone/MessageStore.swift @@ -0,0 +1,195 @@ +// +// MessageStore.swift +// SlackClone +// +// Created by Guilherme Souza on 18/01/24. +// + +import Foundation +import IdentifiedCollections +import Supabase + +struct Messages { + private(set) var sections: [Section] + + struct Section: Hashable, Identifiable { + var id: AnyHashable { self } + + var author: User + var messages: IdentifiedArrayOf + } + + init(sections: [Section]) { + self.sections = sections + + for (index, section) in sections.enumerated() { + for message in section.messages { + messageToSectionLookupTable[message.id] = index + } + } + } + + private var messageToSectionLookupTable: [Message.ID: Int] = [:] + + mutating func appendOrUpdate(_ message: Message) { + if let sectionIndex = messageToSectionLookupTable[message.id], + let messageIndex = sections[sectionIndex].messages + .firstIndex(where: { $0.id == message.id }) + { + sections[sectionIndex].messages[messageIndex] = message + } else { + append(message) + } + } + + mutating func remove(id: Message.ID) { + if let index = messageToSectionLookupTable[id] { + sections[index].messages.remove(id: id) + messageToSectionLookupTable[id] = nil + if sections[index].messages.isEmpty { + sections.remove(at: index) + rebuildLookupTable() + } + } + } + + private mutating func append(_ message: Message) { + if var section = sections.last, section.author.id == message.user.id { + section.messages.append(message) + sections[sections.endIndex - 1] = section + } else { + let section = Section(author: message.user, messages: [message]) + sections.append(section) + } + + messageToSectionLookupTable[message.id] = sections.endIndex - 1 + } + + private mutating func rebuildLookupTable() { + messageToSectionLookupTable = [:] + + for (index, section) in sections.enumerated() { + for message in section.messages { + messageToSectionLookupTable[message.id] = index + } + } + } +} + +extension Messages { + init(_ messages: [Message]) { + self.init(sections: []) + + for message in messages { + append(message) + } + } +} + +@MainActor +@Observable +final class MessageStore { + static let shared = MessageStore() + + private(set) var messages: [Channel.ID: Messages] = [:] + + struct Section { + var author: User + var messages: [Message] + } + + var users: UserStore { Dependencies.shared.users } + var channel: ChannelStore { Dependencies.shared.channel } + + private init() { + Task { + let channel = await supabase.realtimeV2.channel("public:messages") + + let insertions = await channel.postgresChange(InsertAction.self, table: "messages") + let updates = await channel.postgresChange(UpdateAction.self, table: "messages") + let deletions = await channel.postgresChange(DeleteAction.self, table: "messages") + + await channel.subscribe() + + Task { + for await insertion in insertions { + await handleInsertedOrUpdatedMessage(insertion) + } + } + + Task { + for await update in updates { + await handleInsertedOrUpdatedMessage(update) + } + } + + Task { + for await delete in deletions { + handleDeletedMessage(delete) + } + } + } + } + + func loadInitialMessages(_ channelId: Channel.ID) async { + do { + let allMessages = try await fetchMessages(channelId) + messages[channelId] = Messages(allMessages) + } catch { + dump(error) + } + } + + func removeMessages(for channel: Channel.ID) { + messages[channel] = nil + } + + private func handleInsertedOrUpdatedMessage(_ action: HasRecord) async { + do { + let decodedMessage = try action.decodeRecord(decoder: decoder) as MessagePayload + let message = try await Message( + id: decodedMessage.id, + insertedAt: decodedMessage.insertedAt, + message: decodedMessage.message, + user: users.fetchUser(id: decodedMessage.userId), + channel: channel.fetchChannel(id: decodedMessage.channelId) + ) + + var channelMessages = messages[decodedMessage.channelId] ?? Messages(sections: []) + channelMessages.appendOrUpdate(message) + messages[decodedMessage.channelId] = channelMessages + } catch { + dump(error) + } + } + + private func handleDeletedMessage(_ action: DeleteAction) { + guard let id = action.oldRecord["id"]?.intValue else { + return + } + + for (channel, var messages) in messages { + messages.remove(id: id) + self.messages[channel] = messages + } + } + + /// Fetch all messages and their authors. + private func fetchMessages(_ channelId: Channel.ID) async throws -> [Message] { + try await supabase.database + .from("messages") + .select("*,user:user_id(*),channel:channel_id(*)") + .eq("channel_id", value: channelId) + .order("inserted_at", ascending: true) + .execute() + .value + } +} + +private struct MessagePayload: Decodable { + let id: Int + let message: String + let insertedAt: Date + let userId: UUID + let channelId: Int +} diff --git a/Examples/SlackClone/MessagesView.swift b/Examples/SlackClone/MessagesView.swift index b3c52cff..968ded30 100644 --- a/Examples/SlackClone/MessagesView.swift +++ b/Examples/SlackClone/MessagesView.swift @@ -9,30 +9,44 @@ import Realtime import Supabase import SwiftUI -struct UserPresence: Codable { - var userId: UUID - var onlineAt: Date -} - @MainActor struct MessagesView: View { - let store = Store.shared.messages + let store = Dependencies.shared.messages + let userStore = Dependencies.shared.users let channel: Channel @State private var newMessage = "" - var messages: [Message] { - store.messages[channel.id, default: []] + var messages: Messages { + store.messages[channel.id] ?? .init(sections: []) } var body: some View { List { - ForEach(messages) { message in - VStack(alignment: .leading) { - Text(message.user.username) - .font(.caption) - .foregroundStyle(.secondary) - Text(message.message) + ForEach(messages.sections) { section in + Section { + ForEach(section.messages) { message in + HStack(alignment: .top) { + Text(message.message) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(message.insertedAt.formatted()) + .font(.footnote) + } + } + } header: { + HStack { + Text(section.author.username) + .font(.caption) + .foregroundStyle(.secondary) + + Image(systemName: "circle.fill") + .foregroundStyle( + userStore.presences[section.author.id] != nil ? Color.green : Color + .red + ) + } } } } @@ -42,7 +56,7 @@ struct MessagesView: View { .safeAreaInset(edge: .bottom) { ComposeMessageView(text: $newMessage) { Task { - try! await submitNewMessageButtonTapped() + await submitNewMessageButtonTapped() } } .padding() @@ -50,14 +64,21 @@ struct MessagesView: View { .navigationTitle(channel.slug) } - private func submitNewMessageButtonTapped() async throws { - let message = try await NewMessage( - message: newMessage, - userId: supabase.auth.session.user.id, - channelId: channel.id - ) + private func submitNewMessageButtonTapped() async { + guard !newMessage.isEmpty else { return } - try await supabase.database.from("messages").insert(message).execute() + do { + let message = try await NewMessage( + message: newMessage, + userId: supabase.auth.session.user.id, + channelId: channel.id + ) + + try await supabase.database.from("messages").insert(message).execute() + newMessage = "" + } catch { + dump(error) + } } } @@ -68,6 +89,10 @@ struct ComposeMessageView: View { var body: some View { HStack { TextField("Type here", text: $text) + .onSubmit { + onSubmit() + } + Button { onSubmit() } label: { diff --git a/Examples/SlackClone/MessagesViewModel.swift b/Examples/SlackClone/MessagesViewModel.swift deleted file mode 100644 index 31fae70c..00000000 --- a/Examples/SlackClone/MessagesViewModel.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// MessagesViewModel.swift -// SlackClone -// -// Created by Guilherme Souza on 18/01/24. -// - -import Foundation -import Supabase - -@MainActor -protocol MessagesStore: AnyObject { - func removeMessages(for channel: Channel.ID) -} - -@MainActor -@Observable -final class MessagesViewModel: MessagesStore { - private(set) var messages: [Channel.ID: [Message]] = [:] - - weak var users: UserStore! - weak var channel: ChannelsStore! - - init() { - Task { - let channel = await supabase.realtimeV2.channel("public:messages") - - let insertions = await channel.postgresChange(InsertAction.self, table: "messages") - let updates = await channel.postgresChange(UpdateAction.self, table: "messages") - let deletions = await channel.postgresChange(DeleteAction.self, table: "messages") - - await channel.subscribe() - - Task { - for await insertion in insertions { - await handleInsertedOrUpdatedMessage(insertion) - } - } - - Task { - for await update in updates { - await handleInsertedOrUpdatedMessage(update) - } - } - - Task { - for await delete in deletions { - handleDeletedMessage(delete) - } - } - } - } - - func loadInitialMessages(_ channelId: Channel.ID) async { - do { - messages[channelId] = try await fetchMessages(channelId) - } catch { - dump(error) - } - } - - func removeMessages(for channel: Channel.ID) { - messages[channel] = [] - } - - private func handleInsertedOrUpdatedMessage(_ action: HasRecord) async { - do { - let decodedMessage = try action.decodeRecord(decoder: decoder) as MessagePayload - let message = try await Message( - id: decodedMessage.id, - insertedAt: decodedMessage.insertedAt, - message: decodedMessage.message, - user: users.fetchUser(id: decodedMessage.userId), - channel: channel.fetchChannel(id: decodedMessage.channelId) - ) - - if let index = messages[decodedMessage.channelId, default: []] - .firstIndex(where: { $0.id == message.id }) - { - messages[decodedMessage.channelId]?[index] = message - } else { - messages[decodedMessage.channelId]?.append(message) - } - } catch { - dump(error) - } - } - - private func handleDeletedMessage(_ action: DeleteAction) { - guard let id = action.oldRecord["id"]?.intValue else { - return - } - - let allMessages = messages.flatMap(\.value) - guard let message = allMessages.first(where: { $0.id == id }) else { return } - - messages[message.channel.id]?.removeAll(where: { $0.id == message.id }) - } - - /// Fetch all messages and their authors. - private func fetchMessages(_ channelId: Channel.ID) async throws -> [Message] { - try await supabase.database - .from("messages") - .select("*,user:user_id(*),channel:channel_id(*)") - .eq("channel_id", value: channelId) - .order("inserted_at", ascending: true) - .execute() - .value - } -} - -private struct MessagePayload: Decodable { - let id: Int - let message: String - let insertedAt: Date - let userId: UUID - let channelId: Int -} diff --git a/Examples/SlackClone/Store.swift b/Examples/SlackClone/Store.swift deleted file mode 100644 index 610c07f2..00000000 --- a/Examples/SlackClone/Store.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// Store.swift -// SlackClone -// -// Created by Guilherme Souza on 04/01/24. -// - -import Foundation -import Supabase - -@MainActor -@Observable -class Store { - static let shared = Store() - - let channel: ChannelsViewModel - let users: UserStore - let messages: MessagesViewModel - - private init() { - channel = ChannelsViewModel() - users = UserStore() - messages = MessagesViewModel() - - channel.messages = messages - messages.channel = channel - messages.users = users - } -} - -struct User: Codable, Identifiable { - var id: UUID - var username: String -} - -struct Channel: Identifiable, Codable, Hashable { - var id: Int - var slug: String - var insertedAt: Date -} - -struct Message: Identifiable, Decodable { - var id: Int - var insertedAt: Date - var message: String - var user: User - var channel: Channel -} - -struct NewMessage: Codable { - var message: String - var userId: UUID - let channelId: Int -} diff --git a/Examples/SlackClone/Supabase.swift b/Examples/SlackClone/Supabase.swift index be95f73f..c3bb6c20 100644 --- a/Examples/SlackClone/Supabase.swift +++ b/Examples/SlackClone/Supabase.swift @@ -25,12 +25,6 @@ let supabase = SupabaseClient( supabaseKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", options: SupabaseClientOptions( db: .init(encoder: encoder, decoder: decoder), - global: SupabaseClientOptions.GlobalOptions(logger: Logger()) + global: SupabaseClientOptions.GlobalOptions(logger: SupabaseLoggerImpl()) ) ) - -struct Logger: SupabaseLogger { - func log(message: SupabaseLogMessage) { - print(message) - } -} diff --git a/Examples/SlackClone/UserStore.swift b/Examples/SlackClone/UserStore.swift index 60029f35..ebd34bc0 100644 --- a/Examples/SlackClone/UserStore.swift +++ b/Examples/SlackClone/UserStore.swift @@ -6,22 +6,50 @@ // import Foundation +import OSLog import Supabase @MainActor @Observable final class UserStore { + static let shared = UserStore() + private(set) var users: [User.ID: User] = [:] + private(set) var presences: [User.ID: UserPresence] = [:] - init() { + private init() { Task { let channel = await supabase.realtimeV2.channel("public:users") let changes = await channel.postgresChange(AnyAction.self, table: "users") + let prenseces = await channel.presenceChange() + await channel.subscribe() - for await change in changes { - handleChangedUser(change) + let userId = try await supabase.auth.session.user.id + try await channel.track(UserPresence(userId: userId, onlineAt: Date())) + + Task { + for await change in changes { + handleChangedUser(change) + } + } + + Task { + for await presence in prenseces { + let joins = try presence.decodeJoins(as: UserPresence.self) + let leaves = try presence.decodeLeaves(as: UserPresence.self) + + for join in joins { + self.presences[join.userId] = join + Logger.main.debug("User \(join.userId) joined") + } + + for leave in leaves { + self.presences[leave.userId] = nil + Logger.main.debug("User \(leave.userId) leaved") + } + } } } }