diff --git a/src/main/kotlin/chatserver/ChatServer.kt b/src/main/kotlin/chatserver/ChatServer.kt index 2dfc874..9ccedf0 100644 --- a/src/main/kotlin/chatserver/ChatServer.kt +++ b/src/main/kotlin/chatserver/ChatServer.kt @@ -10,4 +10,5 @@ val chatServerModule = module { includes(chatServerProfilesModule, chatServerMessagesModule, chatServerPresenceModule) factoryOf(::ChatRepositories) + factoryOf(::LoggedInUserId) } diff --git a/src/main/kotlin/chatserver/LoggedInUserId.kt b/src/main/kotlin/chatserver/LoggedInUserId.kt new file mode 100644 index 0000000..249e0f7 --- /dev/null +++ b/src/main/kotlin/chatserver/LoggedInUserId.kt @@ -0,0 +1,8 @@ +package chatserver + +import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.auth.auth + +class LoggedInUserId(private val client: SupabaseClient) { + operator fun invoke(): String = client.auth.currentUserOrNull()?.id.orEmpty() +} diff --git a/src/main/kotlin/chatserver/ProfileResult.kt b/src/main/kotlin/chatserver/ProfileResult.kt index 2dca804..633c84c 100644 --- a/src/main/kotlin/chatserver/ProfileResult.kt +++ b/src/main/kotlin/chatserver/ProfileResult.kt @@ -1,15 +1,16 @@ package chatserver import chatserver.ReadChatRepository.ReadResult +import chatserver.profiles.SupabaseProfilesRepository.Profile data class ProfileResult( override val isOk: Boolean, - override val item: Map, + override val item: Map, override val error: Exception?, ) : - ReadResult> { + ReadResult> { companion object { - fun ok(item: Map): ProfileResult = ProfileResult(true, item, null) + fun ok(item: Map): ProfileResult = ProfileResult(true, item, null) fun fail(e: Exception? = null): ProfileResult = ProfileResult(false, emptyMap(), e) } diff --git a/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt b/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt index 1b3816d..2e0e185 100644 --- a/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt +++ b/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt @@ -15,4 +15,13 @@ val chatServerProfilesModule = ), ) } + single { + SupabaseProfilesRepository( + get(), + get(), + CoroutineScope( + SupervisorJob() + get().io, + ), + ) + } } diff --git a/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt b/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt index 759ad0b..cccaa90 100644 --- a/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt +++ b/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt @@ -7,6 +7,7 @@ import chatserver.ReadChatRepository import chatserver.SubscribeChatRepository import chatserver.WriteChatRepository import chatserver.messages.LocalChatMessages +import chatserver.profiles.SupabaseProfilesRepository.Profile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -33,7 +34,7 @@ class LocalProfilesRepository( scope.launch(dispatchers.default) { while (true) { val users = LocalChatMessages.sampleUsers.shuffled() - state.value = users.associateWith { it }.let(ProfileResult::ok) + state.value = users.associateWith { Profile(it, it) }.let(ProfileResult::ok) delay(5.seconds) } } @@ -50,7 +51,7 @@ class LocalProfilesRepository( delay(2.seconds) check(item.isValidUsername()) { "Could not assign current username." } val profiles = latest().item.toMutableMap() - profiles[item] = item + profiles[item] = Profile(item, item) state.value = ok(profiles) } catch (e: Exception) { state.value = ProfileResult.fail(e) diff --git a/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt new file mode 100644 index 0000000..fc20dc2 --- /dev/null +++ b/src/main/kotlin/chatserver/profiles/SupabaseProfilesRepository.kt @@ -0,0 +1,74 @@ +package chatserver.profiles + +import chatserver.* +import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.annotations.SupabaseExperimental +import io.github.jan.supabase.postgrest.from +import io.github.jan.supabase.realtime.selectAsFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +class SupabaseProfilesRepository( + private val client: SupabaseClient, + private val userId: LoggedInUserId, + private val scope: CoroutineScope, +) : ReadChatRepository, WriteChatRepository, SubscribeChatRepository { + private val profiles: MutableStateFlow = MutableStateFlow(ProfileResult.ok(emptyMap())) + + override fun latest(): ProfileResult = profiles.value + + override fun observe(): Flow = profiles.asStateFlow() + + override fun write(requestedUsername: String) { + require(requestedUsername.isBlank()) { + "Requested username must not be blank." + } + val id = + userId().ifBlank { + throw IllegalStateException("User ID must not be blank.") + } + val currentUsername = latest().item[id]?.username + check(currentUsername == requestedUsername) { + "Current username must not match requested username." + } + scope.launch { + try { + client.from("profiles") + .update({ + set("username", requestedUsername) + }) { + filter { + eq("id", id) + } + } + } catch (e: Exception) { + profiles.value = ProfileResult.fail(e) + } + } + } + + @OptIn(SupabaseExperimental::class) + override fun subscribe() { + client.from("profiles") + .selectAsFlow(Profile::id) + .map { it.associateBy(Profile::id) } + .map { ProfileResult.ok(it) } + .onEach { profiles.value = it } + .catch { println(it) } + .launchIn(scope) + } + + override fun unsubscribe() { + scope.cancel() + } + + @Serializable + data class Profile( + @SerialName("id") val id: String, + @SerialName("username") val username: String, + ) +} diff --git a/src/test/kotlin/profile/ProfilePresenterTest.kt b/src/test/kotlin/profile/ProfilePresenterTest.kt index 4c33f01..2ef1378 100644 --- a/src/test/kotlin/profile/ProfilePresenterTest.kt +++ b/src/test/kotlin/profile/ProfilePresenterTest.kt @@ -4,6 +4,7 @@ import arch.RokyDispatchers import chatserver.ProfileResult import chatserver.ReadChatRepository import chatserver.WriteChatRepository +import chatserver.profiles.SupabaseProfilesRepository.Profile import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -43,13 +44,13 @@ class ProfilePresenterTest { } val writeUsername: WriteChatRepository = mockk() every { writeUsername.write(any()) } answers { - val user = it.invocation.args [0] as String + val user = it.invocation.args[0] as String usernames.value = if (user == INVALID_USER) { ProfileResult.fail(RuntimeException()) } else { val currentUsernames = usernames.value.item.toMutableMap() - currentUsernames[user] = user + currentUsernames[user] = Profile(user, user) ProfileResult.ok(currentUsernames) } }