Initial import of f7cloud-mobile native Android app.
Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support). Current version: 0.5.113 (build 121).
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
package ru.forbion.f7cloud.feature.widgets
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
|
||||
class WidgetsRepository {
|
||||
suspend fun loadDashboard(session: AuthSession): List<DashboardWidgetGroup> = coroutineScope {
|
||||
val client = NetworkFactory.newAuthedClient(
|
||||
session.username,
|
||||
session.appPassword,
|
||||
session.trustAllCerts,
|
||||
)
|
||||
val base = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/dashboard"
|
||||
|
||||
val widgetsDeferred = async(Dispatchers.IO) {
|
||||
parseWidgets(getOcsData(client, "$base/api/v1/widgets"))
|
||||
}
|
||||
val layoutDeferred = async(Dispatchers.IO) {
|
||||
fetchLayoutWithTimeout(client, base)
|
||||
}
|
||||
val itemsV2Deferred = async(Dispatchers.IO) {
|
||||
parseWidgetItemsV2(getOcsData(client, "$base/api/v2/widget-items?limit=7"))
|
||||
}
|
||||
// recommendations есть только в API v1
|
||||
val itemsV1Deferred = async(Dispatchers.IO) {
|
||||
parseWidgetItemsV1(
|
||||
getOcsData(
|
||||
client,
|
||||
buildWidgetItemsUrl(base, apiVersion = 1, widgetIds = listOf("recommendations")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val allWidgets = widgetsDeferred.await().associateBy { it.id }
|
||||
val layout = layoutDeferred.await()
|
||||
val itemsByWidget = mergeWidgetItems(itemsV1Deferred.await(), itemsV2Deferred.await())
|
||||
val widgetOrder = resolveWidgetOrder(layout, allWidgets, itemsByWidget.keys)
|
||||
|
||||
widgetOrder.map { widget ->
|
||||
val bucket = itemsByWidget[widget.id]
|
||||
DashboardWidgetGroup(
|
||||
id = widget.id,
|
||||
title = widget.title,
|
||||
iconUrl = widget.iconUrl,
|
||||
items = bucket?.items.orEmpty(),
|
||||
emptyMessage = bucket?.emptyMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLayoutWithTimeout(
|
||||
client: okhttp3.OkHttpClient,
|
||||
base: String,
|
||||
): List<String> = withContext(Dispatchers.IO) {
|
||||
withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
|
||||
runCatching {
|
||||
parseLayout(getOcsData(client, "$base/api/v3/layout"))
|
||||
}.getOrNull()
|
||||
}?.takeIf { it.isNotEmpty() } ?: DEFAULT_LAYOUT
|
||||
}
|
||||
|
||||
private fun buildWidgetItemsUrl(
|
||||
base: String,
|
||||
apiVersion: Int,
|
||||
widgetIds: List<String> = emptyList(),
|
||||
): String {
|
||||
val builder = "$base/api/v$apiVersion/widget-items?limit=7"
|
||||
.toHttpUrlOrNull()
|
||||
?.newBuilder()
|
||||
?: return "$base/api/v$apiVersion/widget-items?limit=7"
|
||||
widgetIds.forEach { builder.addQueryParameter("widgets[]", it) }
|
||||
return builder.build().toString()
|
||||
}
|
||||
|
||||
private fun resolveWidgetOrder(
|
||||
layout: List<String>,
|
||||
allWidgets: Map<String, WidgetMeta>,
|
||||
itemWidgetIds: Set<String>,
|
||||
): List<WidgetMeta> {
|
||||
val order = layout.ifEmpty { DEFAULT_LAYOUT }
|
||||
val seen = mutableSetOf<String>()
|
||||
val ordered = mutableListOf<WidgetMeta>()
|
||||
for (id in order) {
|
||||
if (!seen.add(id)) continue
|
||||
val meta = allWidgets[id] ?: continue
|
||||
if (id in itemWidgetIds || meta.id in allWidgets) {
|
||||
ordered += meta
|
||||
}
|
||||
}
|
||||
for (id in itemWidgetIds) {
|
||||
if (!seen.add(id)) continue
|
||||
allWidgets[id]?.let { ordered += it }
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
private fun mergeWidgetItems(
|
||||
v1: Map<String, WidgetItemsBucket>,
|
||||
v2: Map<String, WidgetItemsBucket>,
|
||||
): Map<String, WidgetItemsBucket> {
|
||||
val ids = (v1.keys + v2.keys).toSet()
|
||||
return ids.associateWith { id ->
|
||||
val fromV2 = v2[id]
|
||||
val fromV1 = v1[id]
|
||||
when {
|
||||
fromV2 != null && fromV2.items.isNotEmpty() -> fromV2
|
||||
fromV1 != null && fromV1.items.isNotEmpty() -> fromV1
|
||||
fromV2 != null -> fromV2
|
||||
else -> fromV1 ?: WidgetItemsBucket()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOcsData(client: okhttp3.OkHttpClient, url: String): Any {
|
||||
val jsonUrl = if (url.contains("format=")) url else {
|
||||
if (url.contains("?")) "$url&format=json" else "$url?format=json"
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url(jsonUrl)
|
||||
.header("OCS-APIRequest", "true")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Dashboard API HTTP ${response.code}")
|
||||
}
|
||||
val body = response.body!!.string()
|
||||
val ocs = JSONObject(body).optJSONObject("ocs")
|
||||
?: error("Некорректный ответ Dashboard API")
|
||||
val meta = ocs.optJSONObject("meta")
|
||||
val status = meta?.optString("status").orEmpty()
|
||||
if (status.equals("failure", ignoreCase = true)) {
|
||||
error(meta?.optString("message").orEmpty().ifBlank { "Dashboard API error" })
|
||||
}
|
||||
return ocs.opt("data") ?: JSONObject()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseLayout(data: Any): List<String> {
|
||||
if (data !is JSONObject) return emptyList()
|
||||
val layout = data.optJSONArray("layout") ?: return emptyList()
|
||||
return buildList {
|
||||
for (i in 0 until layout.length()) {
|
||||
val id = layout.optString(i)
|
||||
if (id.isNotBlank()) add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseWidgets(data: Any): List<WidgetMeta> {
|
||||
val out = mutableListOf<WidgetMeta>()
|
||||
when (data) {
|
||||
is JSONObject -> {
|
||||
val keys = data.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
val obj = data.optJSONObject(key) ?: continue
|
||||
out += widgetMetaFromJson(obj, key)
|
||||
}
|
||||
}
|
||||
is JSONArray -> for (i in 0 until data.length()) {
|
||||
val obj = data.optJSONObject(i) ?: continue
|
||||
out += widgetMetaFromJson(obj, obj.optString("id"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun widgetMetaFromJson(obj: JSONObject, fallbackId: String): WidgetMeta {
|
||||
return WidgetMeta(
|
||||
id = obj.optString("id", fallbackId),
|
||||
title = obj.optString("title", fallbackId),
|
||||
order = obj.optInt("order", 0),
|
||||
iconUrl = obj.optString("icon_url"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseWidgetItemsV1(data: Any): Map<String, WidgetItemsBucket> {
|
||||
val out = mutableMapOf<String, WidgetItemsBucket>()
|
||||
if (data !is JSONObject) return out
|
||||
val keys = data.keys()
|
||||
while (keys.hasNext()) {
|
||||
val widgetId = keys.next()
|
||||
val value = data.opt(widgetId) ?: continue
|
||||
val items = when (value) {
|
||||
is JSONArray -> parseItemArray(value)
|
||||
is JSONObject -> parseItemArray(value.optJSONArray("items") ?: JSONArray())
|
||||
else -> emptyList()
|
||||
}
|
||||
if (items.isNotEmpty()) {
|
||||
out[widgetId] = WidgetItemsBucket(items = items)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parseWidgetItemsV2(data: Any): Map<String, WidgetItemsBucket> {
|
||||
val out = mutableMapOf<String, WidgetItemsBucket>()
|
||||
if (data !is JSONObject) return out
|
||||
val keys = data.keys()
|
||||
while (keys.hasNext()) {
|
||||
val widgetId = keys.next()
|
||||
val widgetObj = data.optJSONObject(widgetId) ?: continue
|
||||
val items = parseItemArray(widgetObj.optJSONArray("items") ?: JSONArray())
|
||||
val emptyMessage = widgetObj.optString("emptyContentMessage")
|
||||
.ifBlank { widgetObj.optString("halfEmptyContentMessage") }
|
||||
.ifBlank { null }
|
||||
if (items.isNotEmpty() || !emptyMessage.isNullOrBlank()) {
|
||||
out[widgetId] = WidgetItemsBucket(items = items, emptyMessage = emptyMessage)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parseItemArray(itemsArray: JSONArray): List<WidgetItem> {
|
||||
val items = mutableListOf<WidgetItem>()
|
||||
for (i in 0 until itemsArray.length()) {
|
||||
val item = itemsArray.optJSONObject(i) ?: continue
|
||||
items += WidgetItem(
|
||||
title = item.optString("title"),
|
||||
subtitle = item.optString("subtitle"),
|
||||
link = item.optString("link"),
|
||||
iconUrl = item.optString("iconUrl"),
|
||||
)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val LAYOUT_TIMEOUT_MS = 2_000L
|
||||
private val DEFAULT_LAYOUT = listOf("recommendations", "spreed", "mail", "calendar")
|
||||
}
|
||||
}
|
||||
|
||||
private data class WidgetMeta(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val order: Int,
|
||||
val iconUrl: String,
|
||||
)
|
||||
|
||||
data class WidgetItem(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val link: String,
|
||||
val iconUrl: String = "",
|
||||
)
|
||||
|
||||
data class DashboardWidgetGroup(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val iconUrl: String = "",
|
||||
val items: List<WidgetItem>,
|
||||
val emptyMessage: String? = null,
|
||||
)
|
||||
|
||||
private data class WidgetItemsBucket(
|
||||
val items: List<WidgetItem> = emptyList(),
|
||||
val emptyMessage: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
package ru.forbion.f7cloud.feature.widgets
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ListCard
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
||||
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
||||
import ru.forbion.f7cloud.feature.files.OfficeFileLinks
|
||||
import ru.forbion.f7cloud.feature.files.OfficeFiles
|
||||
import ru.forbion.f7cloud.feature.files.OfficeWarmup
|
||||
|
||||
@Composable
|
||||
fun WidgetsScreen(
|
||||
session: AuthSession,
|
||||
modifier: Modifier = Modifier,
|
||||
onUnauthorized: () -> Unit = {},
|
||||
onOpenOfficeEditor: (OfficeEditorLaunch) -> Unit = {},
|
||||
onOpenLink: (String) -> Unit = {},
|
||||
) {
|
||||
val vm: WidgetsViewModel = viewModel()
|
||||
val state by vm.state.collectAsState()
|
||||
|
||||
LaunchedEffect(session.serverUrl, session.username) {
|
||||
OfficeWarmup.warm(session)
|
||||
vm.load(session)
|
||||
}
|
||||
LaunchedEffect(state.unauthorized) {
|
||||
if (state.unauthorized) onUnauthorized()
|
||||
}
|
||||
LaunchedEffect(state.editorLaunch) {
|
||||
val launch = state.editorLaunch ?: return@LaunchedEffect
|
||||
onOpenOfficeEditor(launch)
|
||||
vm.clearEditorLaunch()
|
||||
}
|
||||
|
||||
F7ModuleScreen(
|
||||
modifier = modifier,
|
||||
loading = state.loading && state.groups.isEmpty(),
|
||||
error = state.error,
|
||||
onRefresh = { vm.load(session) },
|
||||
) {
|
||||
if (!state.loading && state.groups.isEmpty() && state.error.isNullOrBlank()) {
|
||||
Text(
|
||||
text = "Нет виджетов на главной. Настройте их в веб-интерфейсе.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
if (state.openingFile != null) {
|
||||
CircularProgressIndicator(color = F7Colors.Primary)
|
||||
Text(
|
||||
text = "Открываем «${state.openingFile}»…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(state.groups, key = { it.id }) { group ->
|
||||
F7ListCard {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (group.iconUrl.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = group.iconUrl,
|
||||
contentDescription = group.title,
|
||||
modifier = Modifier.size(24.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
group.title.ifBlank { group.id },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
group.items.forEach { item ->
|
||||
val canOpenOffice = item.link.isNotBlank() &&
|
||||
OfficeFileLinks.parseFileId(item.link, session.serverUrl) != null &&
|
||||
(OfficeFiles.isOfficeFile(item.title) ||
|
||||
OfficeFiles.isOfficeFile(item.link.substringAfterLast('/')))
|
||||
val canOpenLink = item.link.isNotBlank() && !canOpenOffice
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
when {
|
||||
canOpenOffice -> Modifier.clickable { vm.openWidgetItem(session, item) }
|
||||
canOpenLink -> Modifier.clickable { onOpenLink(item.link) }
|
||||
else -> Modifier
|
||||
},
|
||||
)
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (item.iconUrl.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = item.iconUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
androidx.compose.foundation.layout.Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
item.title.ifBlank { "—" },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (item.subtitle.isNotBlank()) {
|
||||
Text(
|
||||
item.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (canOpenOffice) {
|
||||
Text(
|
||||
text = "Открыть в Office",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
} else if (canOpenLink) {
|
||||
Text(
|
||||
text = "Открыть",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (group.items.isEmpty()) {
|
||||
Text(
|
||||
text = group.emptyMessage ?: "Нет элементов",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package ru.forbion.f7cloud.feature.widgets
|
||||
|
||||
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
||||
|
||||
data class WidgetsUiState(
|
||||
val loading: Boolean = false,
|
||||
val groups: List<DashboardWidgetGroup> = emptyList(),
|
||||
val error: String? = null,
|
||||
val unauthorized: Boolean = false,
|
||||
val openingFile: String? = null,
|
||||
val editorLaunch: OfficeEditorLaunch? = null,
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
package ru.forbion.f7cloud.feature.widgets
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
import ru.forbion.f7cloud.feature.files.OfficeFileLinks
|
||||
import ru.forbion.f7cloud.feature.files.OfficeFileOpener
|
||||
import ru.forbion.f7cloud.feature.files.OfficeFiles
|
||||
|
||||
class WidgetsViewModel(
|
||||
private val repository: WidgetsRepository = WidgetsRepository(),
|
||||
private val officeFileOpener: OfficeFileOpener = OfficeFileOpener(),
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(WidgetsUiState())
|
||||
val state: StateFlow<WidgetsUiState> = _state.asStateFlow()
|
||||
|
||||
fun load(session: AuthSession) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
runCatching { repository.loadDashboard(session) }
|
||||
.onSuccess { groups ->
|
||||
_state.value = _state.value.copy(loading = false, groups = groups)
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
error = t.message,
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openWidgetItem(session: AuthSession, item: WidgetItem) {
|
||||
if (item.link.isBlank()) return
|
||||
val fileId = OfficeFileLinks.parseFileId(item.link, session.serverUrl)
|
||||
val isOffice = OfficeFiles.isOfficeFile(item.title) ||
|
||||
OfficeFiles.isOfficeFile(item.link.substringAfterLast('/'))
|
||||
if (fileId == null || !isOffice) {
|
||||
_state.value = _state.value.copy(
|
||||
error = "Открытие через Office доступно только для документов Word/Excel",
|
||||
)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(openingFile = item.title, error = null)
|
||||
runCatching {
|
||||
officeFileOpener.prepareLaunch(
|
||||
session,
|
||||
fileId,
|
||||
OfficeFileLinks.titleFromLink(item.link, item.title),
|
||||
)
|
||||
}
|
||||
.onSuccess { launch ->
|
||||
_state.value = _state.value.copy(
|
||||
openingFile = null,
|
||||
editorLaunch = launch,
|
||||
)
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
openingFile = null,
|
||||
error = t.message ?: "Не удалось открыть документ",
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearEditorLaunch() {
|
||||
_state.value = _state.value.copy(editorLaunch = null)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user