diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumSharingDrawerContent.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumSharingDrawerContent.kt new file mode 100644 index 0000000000000000000000000000000000000000..b06a03988c938f318e393f05de5887b6c2f9f89a --- /dev/null +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumSharingDrawerContent.kt @@ -0,0 +1,110 @@ +/* + * Briar Desktop + * Copyright (C) 2021-2022 The Briar Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package org.briarproject.briar.desktop.forums + +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Info +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.briarproject.bramble.api.sync.GroupId +import org.briarproject.briar.desktop.contact.ContactCard +import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE +import org.briarproject.briar.desktop.ui.HorizontalDivider +import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n +import org.briarproject.briar.desktop.viewmodel.viewModel + +@Composable +fun ForumSharingDrawerContent( + groupId: GroupId, + close: () -> Unit, + viewModel: ForumSharingViewModel = viewModel(), +) = Column { + LaunchedEffect(groupId) { + viewModel.setGroupId(groupId) + } + Row(Modifier.fillMaxWidth().height(HEADER_SIZE)) { + IconButton( + icon = Icons.Filled.Close, + contentDescription = i18n("access.forum.sharing.status.close"), + onClick = close, + modifier = Modifier.padding(start = 24.dp).size(24.dp).align(Alignment.CenterVertically) + ) + Text( + text = i18n("forum.sharing.status.title"), + modifier = Modifier.align(Alignment.CenterVertically).padding(start = 16.dp), + style = MaterialTheme.typography.h3, + ) + } + HorizontalDivider() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = spacedBy(8.dp), + modifier = Modifier.padding(8.dp), + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Text( + text = i18n("forum.sharing.status.info"), + style = MaterialTheme.typography.body2, + ) + } + HorizontalDivider() + Box(Modifier.fillMaxSize()) { + if (viewModel.currentlySharedWith.isEmpty()) { + Text( + text = i18n("forum.sharing.status.nobody"), + style = MaterialTheme.typography.body1, + modifier = Modifier.align(Alignment.Center), + ) + } else { + LazyColumn { + items(viewModel.currentlySharedWith) { contactItem -> + ContactCard( + contactItem, + onSel = {}, + selected = false, + onRemovePending = {}, + ) + } + } + } + } +} diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumSharingViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumSharingViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..3516d975d1b38004cd4d91bf2f30ae507cab8df1 --- /dev/null +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ForumSharingViewModel.kt @@ -0,0 +1,123 @@ +/* + * Briar Desktop + * Copyright (C) 2021-2022 The Briar Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package org.briarproject.briar.desktop.forums + +import androidx.compose.runtime.mutableStateListOf +import mu.KotlinLogging +import org.briarproject.bramble.api.connection.ConnectionRegistry +import org.briarproject.bramble.api.contact.ContactManager +import org.briarproject.bramble.api.db.TransactionManager +import org.briarproject.bramble.api.event.Event +import org.briarproject.bramble.api.event.EventBus +import org.briarproject.bramble.api.lifecycle.LifecycleManager +import org.briarproject.bramble.api.plugin.event.ContactConnectedEvent +import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent +import org.briarproject.bramble.api.sync.GroupId +import org.briarproject.briar.api.attachment.AttachmentReader +import org.briarproject.briar.api.conversation.ConversationManager +import org.briarproject.briar.api.forum.ForumSharingManager +import org.briarproject.briar.api.forum.event.ForumInvitationResponseReceivedEvent +import org.briarproject.briar.api.identity.AuthorManager +import org.briarproject.briar.api.sharing.event.ContactLeftShareableEvent +import org.briarproject.briar.desktop.contact.ContactItem +import org.briarproject.briar.desktop.threading.BriarExecutors +import org.briarproject.briar.desktop.threading.UiExecutor +import org.briarproject.briar.desktop.utils.ImageUtils.loadImage +import org.briarproject.briar.desktop.utils.clearAndAddAll +import org.briarproject.briar.desktop.utils.removeFirst +import org.briarproject.briar.desktop.utils.replaceFirst +import org.briarproject.briar.desktop.viewmodel.EventListenerDbViewModel +import javax.inject.Inject + +class ForumSharingViewModel @Inject constructor( + private val forumSharingManager: ForumSharingManager, + private val contactManager: ContactManager, + private val authorManager: AuthorManager, + private val conversationManager: ConversationManager, + private val connectionRegistry: ConnectionRegistry, + private val attachmentReader: AttachmentReader, + briarExecutors: BriarExecutors, + lifecycleManager: LifecycleManager, + db: TransactionManager, + eventBus: EventBus, +) : EventListenerDbViewModel(briarExecutors, lifecycleManager, db, eventBus) { + + companion object { + private val LOG = KotlinLogging.logger {} + } + + private lateinit var groupId: GroupId + + private val _currentlySharedWith = mutableStateListOf<ContactItem>() + val currentlySharedWith: List<ContactItem> = _currentlySharedWith + + @UiExecutor + fun setGroupId(groupId: GroupId) { + this.groupId = groupId + loadSharedWith() + } + + @UiExecutor + override fun eventOccurred(e: Event) { + when { + e is ForumInvitationResponseReceivedEvent && e.messageHeader.shareableId == groupId && e.messageHeader.wasAccepted() -> + runOnDbThreadWithTransaction(false) { txn -> + val contact = contactManager.getContact(txn, e.contactId) + val authorInfo = authorManager.getAuthorInfo(txn, contact) + val item = ContactItem( + contact, + authorInfo, + connectionRegistry.isConnected(contact.id), + conversationManager.getGroupCount(txn, contact.id), // todo: not necessary to be shown here + authorInfo.avatarHeader?.let { loadImage(txn, attachmentReader, it) }, + ) + txn.attach { _currentlySharedWith.add(item) } + } + + e is ContactLeftShareableEvent && e.groupId == groupId -> + _currentlySharedWith.removeFirst { it.idWrapper.contactId == e.contactId } + + e is ContactConnectedEvent -> + _currentlySharedWith.replaceFirst({ it.idWrapper.contactId == e.contactId }) { it.updateIsConnected(true) } + + e is ContactDisconnectedEvent -> + _currentlySharedWith.replaceFirst({ it.idWrapper.contactId == e.contactId }) { + it.updateIsConnected( + false + ) + } + } + } + + private fun loadSharedWith() = runOnDbThreadWithTransaction(true) { txn -> + val list = forumSharingManager.getSharedWith(txn, groupId).map { contact -> + val authorInfo = authorManager.getAuthorInfo(txn, contact) + ContactItem( + contact, + authorInfo, + connectionRegistry.isConnected(contact.id), + conversationManager.getGroupCount(txn, contact.id), // todo: not necessary to be shown here + authorInfo.avatarHeader?.let { loadImage(txn, attachmentReader, it) }, + ) + } + txn.attach { + _currentlySharedWith.clearAndAddAll(list) + } + } +} diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt index 7e75ef5ac50a871c3b3ba035b9f1133e916f7792..c6aa4e1239277d217234589cacf555900a594219 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/GroupConversationScreen.kt @@ -53,6 +53,7 @@ import org.briarproject.briar.desktop.contact.ContactDropDown.State.CLOSED import org.briarproject.briar.desktop.contact.ContactDropDown.State.MAIN import org.briarproject.briar.desktop.ui.Constants.HEADER_SIZE import org.briarproject.briar.desktop.ui.HorizontalDivider +import org.briarproject.briar.desktop.ui.getInfoDrawerHandler import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n @Composable @@ -91,6 +92,7 @@ private fun GroupConversationHeader( val deleteGroupDialogVisible = remember { mutableStateOf(false) } val menuState = remember { mutableStateOf(CLOSED) } val close = { menuState.value = CLOSED } + val infoDrawerHandler = getInfoDrawerHandler() Box(modifier = Modifier.fillMaxWidth().height(HEADER_SIZE + 1.dp)) { Row( horizontalArrangement = SpaceBetween, @@ -127,6 +129,22 @@ private fun GroupConversationHeader( expanded = menuState.value == MAIN, onDismissRequest = close, ) { + DropdownMenuItem( + onClick = { + close() + infoDrawerHandler.open { + ForumSharingDrawerContent( + groupId = groupItem.id, + close = infoDrawerHandler::close, + ) + } + } + ) { + Text( + i18n("forum.sharing.status.title"), + style = MaterialTheme.typography.body2, + ) + } DropdownMenuItem( onClick = { close() diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/InfoDrawer.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/InfoDrawer.kt new file mode 100644 index 0000000000000000000000000000000000000000..e18b9d169a8c4fc9e929d426d9de453f275a1d3a --- /dev/null +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/InfoDrawer.kt @@ -0,0 +1,187 @@ +/* + * Briar Desktop + * Copyright (C) 2021-2022 The Briar Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package org.briarproject.briar.desktop.ui + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.material.DrawerDefaults +import androidx.compose.material.DrawerValue +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalDrawer +import androidx.compose.material.Surface +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import org.briarproject.briar.desktop.ui.Constants.COLUMN_WIDTH +import kotlin.math.roundToInt + +/** + * State of the [InfoDrawer] composable. + * + * @param initialValue The initial value of the state. + */ +@Stable +class InfoDrawerState( + initialValue: DrawerValue, +) { + /** + * Whether the drawer is open. + */ + val isOpen: Boolean + get() = currentValue == DrawerValue.Open + + /** + * Whether the drawer is closed. + */ + val isClosed: Boolean + get() = currentValue == DrawerValue.Closed + + /** + * The current value of the state. + */ + var currentValue: DrawerValue by mutableStateOf(initialValue) + private set + + /** + * Open the drawer. + */ + fun open() { + currentValue = DrawerValue.Open + } + + /** + * Close the drawer. + */ + fun close() { + currentValue = DrawerValue.Closed + } +} + +/** + * Create and [remember] an [InfoDrawerState]. + * + * @param initialValue The initial value of the state. + */ +@Composable +fun rememberInfoDrawerState(initialValue: DrawerValue): InfoDrawerState { + return remember { + InfoDrawerState(initialValue) + } +} + +/** + * Material Design modal info drawer. + * + * Modal drawers block interaction with the rest of an app’s content with a scrim. + * They are elevated above most of the app’s UI and don’t affect the screen’s layout grid. + * + * This Composable is heavily inspired by [ModalDrawer], but simplified and adapted to our use-case + * of a modal drawer opening from the end of the screen. + * + * @param drawerContent composable that represents content inside the drawer + * @param modifier optional modifier for the drawer + * @param drawerState state of the drawer + * @param drawerShape shape of the drawer sheet + * @param drawerElevation drawer sheet elevation. This controls the size of the shadow below the + * drawer sheet + * @param drawerBackgroundColor background color to be used for the drawer sheet + * @param drawerContentColor color of the content to use inside the drawer sheet. Defaults to + * either the matching content color for [drawerBackgroundColor], or, if it is not a color from + * the theme, this will keep the same value set above this Surface. + * @param scrimColor color of the scrim that obscures content when the drawer is open + * @param content content of the rest of the UI + * + * @throws IllegalStateException when parent has [Float.POSITIVE_INFINITY] width + */ +@Composable +fun InfoDrawer( + drawerContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + drawerState: InfoDrawerState = rememberInfoDrawerState(DrawerValue.Closed), + drawerShape: Shape = MaterialTheme.shapes.large, + drawerElevation: Dp = DrawerDefaults.Elevation, + drawerBackgroundColor: Color = MaterialTheme.colors.surface, + drawerContentColor: Color = contentColorFor(drawerBackgroundColor), + scrimColor: Color = DrawerDefaults.scrimColor, + content: @Composable () -> Unit, +) { + val animatedOffset by animateDpAsState(if (drawerState.isClosed) COLUMN_WIDTH else 0.dp) + BoxWithConstraints(modifier.fillMaxSize()) { + Box { content() } + Scrim( + open = drawerState.isOpen, + onClose = { drawerState.close() }, + color = scrimColor + ) + Surface( + modifier = Modifier + .fillMaxHeight() + .requiredWidth(COLUMN_WIDTH) + .align(Alignment.CenterEnd) + .offset { IntOffset(animatedOffset.value.roundToInt(), 0) }, + shape = drawerShape, + color = drawerBackgroundColor, + contentColor = drawerContentColor, + elevation = drawerElevation, + content = drawerContent + ) + } +} + +@Composable +private fun Scrim( + open: Boolean, + onClose: () -> Unit, + color: Color, +) { + val alpha by animateFloatAsState(if (open) 1f else 0f) + val dismissDrawer = if (open) { + Modifier.pointerInput(onClose) { detectTapGestures { onClose() } } + } else { + Modifier + } + + Canvas( + Modifier + .fillMaxSize() + .then(dismissDrawer) + ) { + drawRect(color, alpha = alpha) + } +} diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt index 0e2dfd36d70490888ba8ce97b7497e99f6c2d8b8..4dcdf9c52c98192b165f3c78641c0b546f2fb592 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/MainScreen.kt @@ -19,7 +19,14 @@ package org.briarproject.briar.desktop.ui import androidx.compose.foundation.layout.Row +import androidx.compose.material.DrawerValue import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import org.briarproject.briar.desktop.conversation.PrivateMessageScreen import org.briarproject.briar.desktop.forums.ForumScreen import org.briarproject.briar.desktop.navigation.BriarSidebar @@ -35,20 +42,66 @@ import org.briarproject.briar.desktop.viewmodel.viewModel */ @Composable fun MainScreen(viewModel: SidebarViewModel = viewModel()) { - Row { - BriarSidebar( - viewModel.account.value, - viewModel.uiMode.value, - viewModel::setUiMode, - ) - VerticalDivider() - when (viewModel.uiMode.value) { - UiMode.CONTACTS -> PrivateMessageScreen() - UiMode.GROUPS -> PrivateGroupScreen() - UiMode.FORUMS -> ForumScreen() - UiMode.SETTINGS -> SettingsScreen() - UiMode.ABOUT -> AboutScreen() - else -> UiPlaceholder() + val drawerHandler = remember { InfoDrawerHandler() } + InfoDrawer( + drawerState = drawerHandler.state, + drawerContent = { + drawerHandler.content() } + ) { + CompositionLocalProvider(LocalInfoDrawerHandler provides drawerHandler) { + Row { + BriarSidebar( + viewModel.account.value, + viewModel.uiMode.value, + viewModel::setUiMode, + ) + VerticalDivider() + when (viewModel.uiMode.value) { + UiMode.CONTACTS -> PrivateMessageScreen() + UiMode.GROUPS -> PrivateGroupScreen() + UiMode.FORUMS -> ForumScreen() + UiMode.SETTINGS -> SettingsScreen() + UiMode.ABOUT -> AboutScreen() + else -> UiPlaceholder() + } + } + } + } +} + +val LocalInfoDrawerHandler = staticCompositionLocalOf<InfoDrawerHandler?> { null } + +@Composable +fun getInfoDrawerHandler() = checkNotNull(LocalInfoDrawerHandler.current) { + "No InfoDrawerHandler was provided via LocalInfoDrawerHandler" // NON-NLS +} + +/** + * Handler to interact with the current [InfoDrawer]. + * Should be provided via [LocalInfoDrawerHandler] and retrieved using [getInfoDrawerHandler]. + */ +class InfoDrawerHandler( + val state: InfoDrawerState = InfoDrawerState(DrawerValue.Closed), + initialContent: @Composable () -> Unit = {}, +) { + + var content by mutableStateOf(initialContent) + private set + + /** + * Open the associated [InfoDrawer] with the given [content]. + * + * @param content Composable content to be shown in the [InfoDrawer]. + * May be null, in which case the last content will be shown again. + */ + fun open(content: (@Composable () -> Unit)? = null) { + if (content != null) this.content = content + state.open() } + + /** + * Close the associated [InfoDrawer]. + */ + fun close() = state.close() } diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt index bcb43539967d35b874c42033ecf5b643927ed529..6cccf37d388fb3ca6aa9eaea1ceb4c1b05913237 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/viewmodel/ViewModelModule.kt @@ -25,6 +25,7 @@ import dagger.multibindings.IntoMap import org.briarproject.briar.desktop.contact.ContactListViewModel import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel import org.briarproject.briar.desktop.conversation.ConversationViewModel +import org.briarproject.briar.desktop.forums.ForumSharingViewModel import org.briarproject.briar.desktop.forums.ForumViewModel import org.briarproject.briar.desktop.introduction.IntroductionViewModel import org.briarproject.briar.desktop.login.StartupViewModel @@ -84,6 +85,11 @@ abstract class ViewModelModule { @ViewModelKey(ThreadedConversationViewModel::class) abstract fun bindThreadedConversationViewModel(threadedConversationViewModel: ThreadedConversationViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(ForumSharingViewModel::class) + abstract fun bindForumSharingViewModel(forumSharingViewModel: ForumSharingViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(SettingsViewModel::class) diff --git a/briar-desktop/src/main/resources/strings/BriarDesktop.properties b/briar-desktop/src/main/resources/strings/BriarDesktop.properties index c0888d808d1bcd84c3076001179cac8689746241..27063d77996d33152d951b880ecbc7ae099cc5f2 100644 --- a/briar-desktop/src/main/resources/strings/BriarDesktop.properties +++ b/briar-desktop/src/main/resources/strings/BriarDesktop.properties @@ -67,6 +67,7 @@ access.forums.unread_count={0, plural, one {one unread posts} other {{0} unread access.forums.last_post_timestamp=last post: {0} access.forums.jump_to_prev_unread=Jump to previous unread post access.forums.jump_to_next_unread=Jump to next unread post +access.forum.sharing.status.close=Close sharing status # Contacts contacts.none_selected.title=No contact selected @@ -127,6 +128,10 @@ forum.message.reply.intro=Reply to: forum.message.new=Unread Post group.card.no_posts=No posts group.card.posts={0, plural, one {{0} post} other {{0} posts}} +forum.sharing.status.title=Sharing Status +forum.sharing.status.info=Any member of a forum can share it with their contacts. You are sharing this forum with the following contacts. There may also be other members who you can't see in this list, although you can see their posts in the forum. +forum.sharing.status.with=Shared with {0} ({1} online) +forum.sharing.status.nobody=Nobody # Private Groups groups.card.created=Created by {0}