diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 606b81d857..c60013928a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -107,6 +107,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzForumPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzInviteScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzNewDmScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzRelayImportScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspacesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen @@ -465,6 +466,7 @@ fun BuildNavigation( composableFromEnd { BuzzWorkspacesScreen(accountViewModel, nav) } composableFromEnd { BuzzDmListScreen(accountViewModel, nav) } composableFromEnd { BuzzNewDmScreen(accountViewModel, nav) } + composableFromEndArgs { BuzzRelayImportScreen(it.relayUrl, accountViewModel, nav) } composableFromEnd { FollowPacksScreen(accountViewModel, nav) } composableFromEnd { LiveStreamsScreen(accountViewModel, nav) } composableFromEnd { NestsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index c5db3ef3d4..7152cc7a19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -725,6 +725,11 @@ sealed class Route { @Serializable object BuzzNewDm : Route() + @Serializable + data class BuzzRelayImport( + val relayUrl: String, + ) : Route() + @Serializable object RelayGroupBrowse : Route() // Concord Channels (encrypted communities). Addressed by community id + channel id diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportScreen.kt new file mode 100644 index 0000000000..fba5f0ed6e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportScreen.kt @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** + * Import the workspace channels the user already belongs to on a Buzz relay (joined via the Buzz + * web/desktop app) into Amethyst. Because Buzz membership is server-side with no public directory, + * this reads the relay's kind-44100 member-added notifications for the user, lists their non-DM + * channels, and adds the chosen ones to the kind-10009 list so they surface in Messages and load at + * boot. See [BuzzRelayImportViewModel]. + */ +@Composable +fun BuzzRelayImportScreen( + relayUrl: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: BuzzRelayImportViewModel = viewModel(key = "BuzzRelayImport-$relayUrl") + viewModel.bind(accountViewModel.account, relayUrl) + + val status by viewModel.status.collectAsStateWithLifecycle() + val channels by viewModel.channels.collectAsStateWithLifecycle() + val added by viewModel.added.collectAsStateWithLifecycle() + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(R.string.buzz_import_title), nav) }, + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + when (val s = status) { + is BuzzRelayImportViewModel.Status.Loading -> + CenteredMessage { + CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp) + Spacer(Modifier.size(12.dp)) + Text(stringRes(R.string.buzz_import_loading), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + is BuzzRelayImportViewModel.Status.Error -> + CenteredMessage { + Text(s.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyLarge) + } + + is BuzzRelayImportViewModel.Status.Ready -> + if (channels.isEmpty()) { + CenteredMessage { + EmptyImport() + } + } else { + val allAdded = channels.all { it.id in added } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(R.string.buzz_import_your_channels), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (!allAdded) { + FilledTonalButton(onClick = { viewModel.addAll() }) { + Text(stringRes(R.string.buzz_import_add_all)) + } + } + } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(channels, key = { it.id }) { groupId -> + ImportRow( + groupId = groupId, + isAdded = groupId.id in added, + onAdd = { viewModel.add(groupId) }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } + } + } +} + +@Composable +private fun ImportRow( + groupId: GroupId, + isAdded: Boolean, + onAdd: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) } + val channelState by observeChannel(baseChannel, accountViewModel) + val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel + + val name = channel.toBestDisplayName() + val memberCount = channel.memberCount() + + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ImportAvatar(name = name, seed = groupId.id) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (memberCount > 0) { + Text( + text = "$memberCount", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (isAdded) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + text = stringRes(R.string.buzz_import_added), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } else { + OutlinedButton(onClick = onAdd) { + Text(stringRes(R.string.buzz_import_add)) + } + } + } + } +} + +/** A round monogram whose color is derived deterministically from the channel id. */ +@Composable +private fun ImportAvatar( + name: String, + seed: String, +) { + val hue = remember(seed) { (seed.hashCode().toLong() and 0xFFFFFF).toFloat() % 360f } + val color = remember(hue) { Color.hsl(hue, 0.55f, 0.5f) } + val initial = + remember(name) { + name + .trim() + .firstOrNull() + ?.uppercaseChar() + ?.toString() ?: "#" + } + Box( + modifier = Modifier.size(40.dp).clip(CircleShape).background(color), + contentAlignment = Alignment.Center, + ) { + Text(text = initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = Color.White) + } +} + +@Composable +private fun CenteredMessage(content: @Composable () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + content() + } +} + +@Composable +private fun EmptyImport() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier.size(52.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.AutoAwesome, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(26.dp), + ) + } + Text( + text = stringRes(R.string.buzz_import_empty_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringRes(R.string.buzz_import_empty_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt new file mode 100644 index 0000000000..39c473f9ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS +import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent +import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.Collections + +/** + * Backing ViewModel for [BuzzRelayImportScreen]: given a Buzz workspace relay the user is + * already a member of (on the Buzz web/desktop app), it surfaces the workspace channels they + * belong to so they can add them to Amethyst. + * + * Buzz membership is server-side (no NIP-29 kind-10009 join event), so a Buzz relay exposes no + * public group directory — the generic "browse a relay" flow returns nothing. Instead the relay + * addresses each member a kind-44100 member-added notification (`#p` = me). This warm-authenticates + * the relay (NIP-42) and reads those, then fetches each channel's kind-39000 metadata to keep only + * the **non-DM** workspace channels (DMs surface separately). Adding one calls [Account.follow] to + * append it to the user's kind-10009 list — which makes it appear in the Messages list and load at + * boot through the existing relay-group machinery, no separate registry needed. + */ +class BuzzRelayImportViewModel : ViewModel() { + @Volatile private var account: Account? = null + private var relay: NormalizedRelayUrl? = null + + val relayUrl: NormalizedRelayUrl? get() = relay + + sealed interface Status { + data object Loading : Status + + data object Ready : Status + + data class Error( + val message: String, + ) : Status + } + + private val _status = MutableStateFlow(Status.Loading) + val status: StateFlow = _status.asStateFlow() + + /** The non-DM workspace channels the user is a member of on this relay. */ + private val _channels = MutableStateFlow>(emptyList()) + val channels: StateFlow> = _channels.asStateFlow() + + /** Channel ids already present in the user's kind-10009 list (seeded + updated as they add). */ + private val _added = MutableStateFlow>(emptySet()) + val added: StateFlow> = _added.asStateFlow() + + fun bind( + account: Account, + relayUrlStr: String, + ) { + if (this.account != null) return + val normalized = + RelayUrlNormalizer.normalizeOrNull(relayUrlStr) ?: run { + _status.value = Status.Error("Invalid relay URL") + return + } + this.account = account + this.relay = normalized + + // The user came here to import from THIS relay: remember it as a joined workspace (persisted, + // marks the Buzz dialect) and pre-approve NIP-42 auth so the `#p=me` read below is served. + BuzzWorkspaces.join(normalized) + viewModelScope.launch { account.relayAuthLedger.setDecision(normalized.url, RelayAuthDecision.ALLOW) } + + // Seed "already added" from the current kind-10009 list so channels the user already has + // render as added rather than offering a duplicate Add. + _added.value = + account.relayGroupList.liveRelayGroupList.value + .filter { RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == normalized } + .mapTo(mutableSetOf()) { it.groupId } + + discover(account, normalized) + } + + private fun discover( + account: Account, + relay: NormalizedRelayUrl, + ) { + viewModelScope.launch(Dispatchers.IO) { + _status.value = Status.Loading + try { + val myPubkey = account.userProfile().pubkeyHex + val channelIds = Collections.synchronizedSet(HashSet()) + + // 1. Warm-auth then read the relay's kind-44100 member-added notifications for me. + account.client.fetchAllWithHooks( + filters = + mapOf( + relay to + listOf( + Filter( + kinds = listOf(MemberAddedNotificationEvent.KIND), + tags = mapOf("p" to listOf(myPubkey)), + ), + ), + ), + timeoutMs = 8_000, + pendingOnAuthRequired = true, + ) { _, event -> + (event as? MemberAddedNotificationEvent)?.channel()?.let { channelIds.add(it) } + false + } + + // 2. Fetch each channel's NIP-29 metadata (39000-39003) so its name + Buzz `t` type load. + if (channelIds.isNotEmpty()) { + account.client.fetchAllWithHooks( + filters = mapOf(relay to listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())))), + timeoutMs = 8_000, + pendingOnAuthRequired = true, + ) { _, _ -> false } + } + + // 3. Keep only non-DM workspace channels; a channel whose metadata hasn't arrived + // (type unknown) is optimistically shown as a workspace channel. + _channels.value = + channelIds + .mapNotNull { id -> + val groupId = GroupId(id, relay) + val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) + if (channel.event?.isBuzzDm() == true) null else groupId + }.sortedBy { it.id } + _status.value = Status.Ready + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _status.value = Status.Error(e.message ?: "Could not load your channels") + } + } + } + + /** Append [groupId] to the user's kind-10009 list (public group tag), so it shows in Messages. */ + fun add(groupId: GroupId) { + val account = account ?: return + viewModelScope.launch(Dispatchers.IO) { + val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) + account.follow(channel) + _added.update { it + groupId.id } + } + } + + fun addAll() { + _channels.value.forEach(::add) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt index fbbb7cbe18..f86bcb0716 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -179,6 +180,20 @@ fun RelayGroupBrowseScreen( Text(stringRes(R.string.relay_group_browse_go)) } + // Buzz workspaces expose no public group directory (membership is server-side), so the + // directory above comes back empty for them. This routes to the membership-scoped + // importer that lists the channels you already belong to on the relay you typed. + TextButton( + onClick = { + val normalized = RelayUrlNormalizer.normalizeOrNull(relayUrl.trim()) + if (normalized == null) showError = true else nav.nav(Route.BuzzRelayImport(normalized.url)) + }, + enabled = relayUrl.isNotBlank(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + Text(stringRes(R.string.relay_group_browse_buzz_import)) + } + if (joined.isNotEmpty()) { SectionHeader(stringRes(R.string.relay_group_browse_your_relays)) joined.sorted().forEach { server -> diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fcba6e5da9..9e23ec3582 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2513,6 +2513,7 @@ Browse the groups hosted on a relay. Paste a relay address, or pick one below. Relay address Browse + Using a Buzz workspace? Import your channels Relays you\'re on Popular relays No messages yet @@ -3346,6 +3347,14 @@ This invite has expired. Ask for a new one. Finish accepting the terms in the browser, then come back — your workspace will be waiting. Open my workspaces + Import workspace channels + Finding your channels… + Your channels on this workspace + Add + Add all + Added + No channels found + You don\'t seem to be a member of any channels on this relay yet. Accept the workspace invite in the browser first, then try again. Message Delivery Close