mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
feat: enrich Concord channel list rows with last message + unread counts
The community "server" channel list (ConcordChannelListScreen) showed only an icon and name per channel. It now mirrors the chat-room list feel: each row carries a preview of the last message (author + snippet), the relative time of that message, and an unread-message count badge, with the name/icon/time emphasized while unread. Adds a shared, reactive unread-message counter (concordChannelUnreadCountFlow) that combines each channel's persisted last-read marker with its own note store, plus a shared ConcordUnreadBadge pill (capped at 99+, plural content description). The Concord Channels hub (ConcordHomeScreen) now reuses both: per-channel rows show the real new-message count instead of a bare dot, and a community's badge sums new messages across its channels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e
This commit is contained in:
+128
-22
@@ -60,15 +60,21 @@ 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 com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
|
||||
@@ -254,28 +260,18 @@ fun ConcordChannelListScreen(
|
||||
def.private == true -> MaterialSymbols.Lock
|
||||
else -> MaterialSymbols.Tag
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { nav.nav(Route.Concord(communityId, entry.key)) }
|
||||
.padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = if (canManageChannels) 4.dp else 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
SymbolIcon(
|
||||
symbol = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(name, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1)
|
||||
if (canManageChannels) {
|
||||
ConcordChannelRowMenu(
|
||||
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
|
||||
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
ConcordChannelListRow(
|
||||
communityId = communityId,
|
||||
channelKey = entry.key,
|
||||
channelName = name,
|
||||
icon = icon,
|
||||
isVoice = def.voice == true,
|
||||
canManageChannels = canManageChannels,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = { nav.nav(Route.Concord(communityId, entry.key)) },
|
||||
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
|
||||
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
|
||||
)
|
||||
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
@@ -283,6 +279,116 @@ fun ConcordChannelListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One channel row in the community's server view: its icon, name, a preview of the last message
|
||||
* (author + snippet), the relative time of that message, and an unread-message count badge — plus
|
||||
* the manager-only overflow menu. Name/icon come from the folded Control Plane definition; the
|
||||
* preview, time, and unread count are read reactively from the channel's own message store so they
|
||||
* fill in the moment messages fold in and clear as soon as the channel is opened (last-read advances).
|
||||
*/
|
||||
@Composable
|
||||
private fun ConcordChannelListRow(
|
||||
communityId: String,
|
||||
channelKey: String,
|
||||
channelName: String,
|
||||
icon: MaterialSymbol,
|
||||
isVoice: Boolean,
|
||||
canManageChannels: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
// getOrCreate (not getIfExists): a channel folded on the Control Plane may have no message note
|
||||
// yet; its notes flow then makes the preview/time/unread reactive as the first message arrives.
|
||||
val channel = remember(communityId, channelKey) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) }
|
||||
val channelState by channel
|
||||
.flow()
|
||||
.notes.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val lastNote = channelState.channel.lastNote
|
||||
val unread by
|
||||
remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) }
|
||||
.collectAsStateWithLifecycle(0)
|
||||
val hasUnread = unread > 0
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(start = 16.dp, top = 12.dp, bottom = 12.dp, end = if (canManageChannels) 4.dp else 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
SymbolIcon(
|
||||
symbol = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (hasUnread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
channelName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
ConcordChannelPreviewLine(lastNote, isVoice, accountViewModel)
|
||||
}
|
||||
lastNote?.createdAt()?.let { ts ->
|
||||
Text(
|
||||
timeAgo(ts, LocalContext.current, prefix = ""),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
ConcordUnreadBadge(unread)
|
||||
if (canManageChannels) {
|
||||
ConcordChannelRowMenu(
|
||||
onRename = onRename,
|
||||
onDelete = onDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line message preview under a channel name: the last message's author and a snippet of its
|
||||
* content ("author: hello"), or a muted "No messages yet" placeholder before anything has folded in.
|
||||
* The author name resolves reactively, so it upgrades from a hex fallback to the profile name.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConcordChannelPreviewLine(
|
||||
lastNote: Note?,
|
||||
isVoice: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val event = lastNote?.event
|
||||
val author = lastNote?.author
|
||||
val preview: String =
|
||||
if (event != null && author != null) {
|
||||
val authorName by observeUserName(author, accountViewModel)
|
||||
val body = event.content.take(80).ifBlank { "" }
|
||||
if (body.isBlank()) authorName ?: "" else "${authorName ?: ""}: $body"
|
||||
} else if (event != null) {
|
||||
event.content.take(80)
|
||||
} else {
|
||||
// Voice channels never carry chat notes, so "No messages yet" would read oddly — leave blank.
|
||||
if (isVoice) return
|
||||
stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_no_messages)
|
||||
}
|
||||
Text(
|
||||
preview,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
/** A pending channel create ([channelIdHex] null) or rename target. */
|
||||
private data class ConcordChannelEditor(
|
||||
val channelIdHex: String?,
|
||||
|
||||
+13
-43
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -258,10 +257,9 @@ private fun communityActivity(
|
||||
} ?: 0L
|
||||
|
||||
/**
|
||||
* The number of a community's channels with a message newer than this account last read there —
|
||||
* combines each channel's persisted last-read ([concordChannelLastReadRoute]) against its
|
||||
* [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel.lastNote]. Recomputed on
|
||||
* [revision] so a freshly-folded message flips the badge.
|
||||
* The total number of new messages across a community's channels — the sum, over every channel, of
|
||||
* messages newer than this account last read that channel ([concordChannelUnreadCountFlow]).
|
||||
* Recomputed reactively as messages fold in and as channels are opened (last-read advances).
|
||||
*/
|
||||
@Composable
|
||||
private fun communityUnreadCount(
|
||||
@@ -271,23 +269,14 @@ private fun communityUnreadCount(
|
||||
): Int {
|
||||
if (channelKeys.isEmpty()) return 0
|
||||
// Keyed only on the channel set (not the global revision): each per-channel flow reacts to both
|
||||
// its last-read marker AND the channel's own notes flow, so a folded message flips the badge
|
||||
// its last-read marker AND the channel's own notes flow, so a folded message updates the badge
|
||||
// without tearing down and restarting every flow on every unrelated fold (which reset the badge
|
||||
// to 0 and made it flicker).
|
||||
val flow =
|
||||
remember(communityId, channelKeys) {
|
||||
combine(
|
||||
channelKeys.map { key ->
|
||||
val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, key))
|
||||
combine(
|
||||
account.loadLastReadFlow(concordChannelLastReadRoute(communityId, key)),
|
||||
channel.flow().notes.stateFlow,
|
||||
) { lastRead, state ->
|
||||
val last = state.channel.lastNote?.createdAt() ?: 0L
|
||||
if (last > lastRead) 1 else 0
|
||||
}
|
||||
},
|
||||
) { flags -> flags.sum() }
|
||||
channelKeys.map { key -> concordChannelUnreadCountFlow(account, communityId, key) },
|
||||
) { counts -> counts.sum() }
|
||||
}
|
||||
return flow.collectAsStateWithLifecycle(0).value
|
||||
}
|
||||
@@ -357,7 +346,7 @@ private fun CommunityHeader(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (unread > 0) UnreadBadge(unread)
|
||||
ConcordUnreadBadge(unread)
|
||||
SymbolIcon(
|
||||
// ▲ only when fully open; ▼ for both CLOSED and the UNREAD peek ("more to reveal").
|
||||
symbol = if (mode == ChannelExpand.OPEN) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
|
||||
@@ -388,25 +377,6 @@ private fun CommunityBanner(
|
||||
)
|
||||
}
|
||||
|
||||
/** A small pill showing the unread-channel count next to a community. */
|
||||
@Composable
|
||||
private fun UnreadBadge(count: Int) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
count.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One channel row with its last message (author + snippet), relative time, and an unread marker —
|
||||
* bold + a dot when there's a message newer than this account last read the channel.
|
||||
@@ -433,8 +403,10 @@ private fun ConcordChannelRow(
|
||||
.notes.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val lastNote = channelState.channel.lastNote
|
||||
val lastReadTime by account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)).collectAsStateWithLifecycle()
|
||||
val unread = (lastNote?.createdAt() ?: Long.MIN_VALUE) > lastReadTime
|
||||
val unreadCount by
|
||||
remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) }
|
||||
.collectAsStateWithLifecycle(0)
|
||||
val unread = unreadCount > 0
|
||||
|
||||
// In the UNREAD peek, a read channel simply isn't shown.
|
||||
if (hideIfRead && !unread) return
|
||||
@@ -486,12 +458,10 @@ private fun ConcordChannelRow(
|
||||
Text(
|
||||
timeAgo(ts, LocalContext.current, prefix = ""),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = if (unread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (unread) {
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary))
|
||||
}
|
||||
ConcordUnreadBadge(unreadCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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.chats.publicChannels.concord
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
||||
/**
|
||||
* A reactive flow of how many messages in [communityId]/[channelKey] are newer than the
|
||||
* timestamp this account last read that channel. It combines the persisted last-read marker
|
||||
* ([concordChannelLastReadRoute]) with the channel's own notes flow, so the count updates both
|
||||
* when a fresh message folds in and when the user opens the channel (which advances last-read).
|
||||
*
|
||||
* Uses `getOrCreate` (not `getIfExists`): a channel folded on the Control Plane may have no
|
||||
* message-buffer note yet, and the flow must still exist so the badge appears the moment its
|
||||
* first message lands. Counting straight off [ConcordChannel.notes] ignores the synthetic
|
||||
* placeholder note (it is never added to that cache) and any note with no timestamp.
|
||||
*/
|
||||
fun concordChannelUnreadCountFlow(
|
||||
account: Account,
|
||||
communityId: String,
|
||||
channelKey: String,
|
||||
): Flow<Int> {
|
||||
val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey))
|
||||
// The notes flow drives recomputation (each new message re-emits its channel state); the count
|
||||
// itself reads the channel's own note store, so it survives the base-typed ChannelState.channel.
|
||||
return combine(
|
||||
account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)),
|
||||
channel.flow().notes.stateFlow,
|
||||
) { lastRead, _ ->
|
||||
channel.newMessagesSince(lastRead)
|
||||
}
|
||||
}
|
||||
|
||||
/** The number of this channel's messages created strictly after [sinceSecs] (0 if none). */
|
||||
private fun ConcordChannel.newMessagesSince(sinceSecs: Long): Int = notes.count { _, note -> (note.createdAt() ?: 0L) > sinceSecs }
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.chats.publicChannels.concord
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
/** Counts above this render as "N+" so a very busy channel doesn't blow out the row. */
|
||||
private const val CONCORD_UNREAD_CAP = 99
|
||||
|
||||
/**
|
||||
* A small pill showing an unread-message [count] (new messages since this account last read).
|
||||
* Renders nothing when [count] is zero, so callers can place it unconditionally. Capped at
|
||||
* [CONCORD_UNREAD_CAP]+ and carries a plural content description for screen readers.
|
||||
*/
|
||||
@Composable
|
||||
fun ConcordUnreadBadge(
|
||||
count: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (count <= 0) return
|
||||
val label = if (count > CONCORD_UNREAD_CAP) "$CONCORD_UNREAD_CAP+" else count.toString()
|
||||
val description = pluralStringResource(R.plurals.concord_unread_messages, count, count)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.semantics { contentDescription = description }
|
||||
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -341,6 +341,11 @@
|
||||
<item quantity="one">%1$d member</item>
|
||||
<item quantity="other">%1$d members</item>
|
||||
</plurals>
|
||||
<plurals name="concord_unread_messages">
|
||||
<item quantity="one">%1$d new message</item>
|
||||
<item quantity="other">%1$d new messages</item>
|
||||
</plurals>
|
||||
<string name="concord_channel_no_messages">No messages yet</string>
|
||||
<string name="concord_create_action">Create</string>
|
||||
<string name="concord_invite_action">Invite people</string>
|
||||
<string name="concord_invite_title">Invite link</string>
|
||||
|
||||
Reference in New Issue
Block a user