diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogInput.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogInput.kt new file mode 100644 index 0000000000000000000000000000000000000000..5b9743813f8d99de33d53b7631cf607944a8c89c --- /dev/null +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogInput.kt @@ -0,0 +1,138 @@ +/* + * Briar Desktop + * Copyright (C) 2021-2023 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.blog + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.TextFieldExt.moveFocusOnTab +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Send +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment.Companion.Top +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIconDefaults +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.dp +import org.briarproject.bramble.util.StringUtils.utf8IsTooLong +import org.briarproject.briar.api.blog.BlogConstants.MAX_BLOG_COMMENT_TEXT_LENGTH +import org.briarproject.briar.api.blog.BlogConstants.MAX_BLOG_POST_TEXT_LENGTH +import org.briarproject.briar.desktop.theme.divider +import org.briarproject.briar.desktop.theme.sendButton +import org.briarproject.briar.desktop.ui.HorizontalDivider +import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n +import org.briarproject.briar.desktop.utils.StringUtils.takeUtf8 + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +fun BlogInput( + selectedBlogPost: BlogPost?, + onReplyClosed: () -> Unit, + onSend: (String) -> Unit, +) { + val messageText = rememberSaveable { mutableStateOf("") } + val maxLength = if (selectedBlogPost == null) MAX_BLOG_POST_TEXT_LENGTH else MAX_BLOG_COMMENT_TEXT_LENGTH + val onSendAction = { + val text = messageText.value + if ((text.isNotBlank() || selectedBlogPost != null) && !utf8IsTooLong(messageText.value, maxLength)) { + onSend(text) + messageText.value = "" + } + } + Column { + if (selectedBlogPost != null) { + Row( + verticalAlignment = Top, + modifier = Modifier.border(1.dp, MaterialTheme.colors.divider), + ) { + Column( + modifier = Modifier + .padding(start = 8.dp, top = 8.dp, bottom = 8.dp) + .weight(1f), + ) { + Text( + text = i18n("blog.post.reply.intro"), + modifier = Modifier.padding(bottom = 8.dp), + ) + BlogPostView( + item = selectedBlogPost, + onItemRepeat = null, + ) + } + IconButton( + icon = Icons.Filled.Close, + contentDescription = i18n("access.blogs.reply.close"), + onClick = onReplyClosed, + ) + } + } + HorizontalDivider() + TextField( + value = messageText.value, + onValueChange = { messageText.value = it.takeUtf8(maxLength) }, + onEnter = onSendAction, + maxLines = 10, + textStyle = MaterialTheme.typography.body1, + placeholder = { + Text( + text = if (selectedBlogPost == null) { + i18n("blog.post.hint") + } else { + i18n("blog.post.reply.hint") + }, + style = MaterialTheme.typography.body1, + ) + }, + modifier = Modifier + .fillMaxWidth() + .moveFocusOnTab(), + shape = RoundedCornerShape(0.dp), + colors = TextFieldDefaults.textFieldColors( + backgroundColor = MaterialTheme.colors.background, + focusedIndicatorColor = MaterialTheme.colors.background, + unfocusedIndicatorColor = MaterialTheme.colors.background + ), + trailingIcon = { + IconButton( + icon = Icons.Filled.Send, + iconTint = MaterialTheme.colors.sendButton, + contentDescription = i18n("access.message.send"), + onClick = onSendAction, + modifier = Modifier + .padding(4.dp) + .size(32.dp) + .pointerHoverIcon(PointerIconDefaults.Default), + ) + } + ) + } +} diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostItem.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostItem.kt new file mode 100644 index 0000000000000000000000000000000000000000..33e02ae3ea77a191d07291396f726f2ab7abda56 --- /dev/null +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostItem.kt @@ -0,0 +1,86 @@ +/* + * Briar Desktop + * Copyright (C) 2023 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.blog + +import org.briarproject.bramble.api.identity.Author +import org.briarproject.bramble.api.sync.GroupId +import org.briarproject.bramble.api.sync.MessageId +import org.briarproject.briar.api.blog.BlogCommentHeader +import org.briarproject.briar.api.blog.BlogPostHeader +import org.briarproject.briar.api.identity.AuthorInfo + +sealed class BlogPost( + open val header: BlogPostHeader, + open val text: String?, +) : Comparable<BlogPostItem> { + abstract val postHeader: BlogPostHeader + val isRead: Boolean get() = header.isRead + val id: MessageId get() = header.id + val groupId: GroupId get() = header.groupId + val timestamp: Long get() = header.timestamp + val author: Author get() = header.author + val authorInfo: AuthorInfo get() = header.authorInfo + val isRssFeed: Boolean get() = header.isRssFeed + + override operator fun compareTo(other: BlogPostItem): Int { + return if (this === other) 0 else other.header.timeReceived.compareTo(header.timeReceived) + } +} + +data class BlogPostItem( + override val header: BlogPostHeader, + override val text: String, +) : BlogPost(header, text) { + override val postHeader: BlogPostHeader get() = header +} + +data class BlogCommentItem( + override val header: BlogCommentHeader, + override val postHeader: BlogPostHeader, + override val text: String?, +) : BlogPost(header, text) { + + companion object { + fun getBlogPostHeader(header: BlogPostHeader): BlogPostHeader { + return if (header is BlogCommentHeader) { + getBlogPostHeader(header.parent) + } else { + header + } + } + } + + private val _comments = ArrayList<BlogCommentHeader>() + val comments: List<BlogCommentHeader> get() = _comments + + init { + collectComments(header) + // TODO check order + _comments.sortBy { it.timestamp } + } + + private fun collectComments(header: BlogPostHeader): BlogPostHeader { + return if (header is BlogCommentHeader) { + if (header.comment != null) _comments.add(header) + collectComments(header.parent) + } else { + header + } + } +} diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostView.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostView.kt new file mode 100644 index 0000000000000000000000000000000000000000..10083b21a36fe0824ec6dc9166b7569fe8c9eaed --- /dev/null +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostView.kt @@ -0,0 +1,260 @@ +/* + * Briar Desktop + * Copyright (C) 2023 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.blog + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.CircleShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Card +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.Repeat +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.BottomEnd +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.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.briarproject.bramble.api.sync.GroupId +import org.briarproject.bramble.api.sync.MessageId +import org.briarproject.briar.api.blog.BlogCommentHeader +import org.briarproject.briar.api.blog.BlogPostHeader +import org.briarproject.briar.api.blog.MessageType +import org.briarproject.briar.api.identity.AuthorInfo +import org.briarproject.briar.api.identity.AuthorInfo.Status.OURSELVES +import org.briarproject.briar.desktop.contact.ProfileCircle +import org.briarproject.briar.desktop.ui.AuthorView +import org.briarproject.briar.desktop.ui.HorizontalDivider +import org.briarproject.briar.desktop.ui.TrustIndicatorShort +import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n +import org.briarproject.briar.desktop.utils.PreviewUtils.preview +import org.briarproject.briar.desktop.utils.TimeUtils +import org.briarproject.briar.desktop.utils.UiUtils +import org.briarproject.briar.desktop.utils.getRandomAuthor +import org.briarproject.briar.desktop.utils.getRandomId +import org.briarproject.briar.desktop.utils.getRandomString +import kotlin.random.Random + +@Suppress("HardCodedStringLiteral") +fun main() = preview { + Column(verticalArrangement = spacedBy(8.dp)) { + val post = getRandomBlogPostItem( + text = "This is a normal blog post.\n\nIt has one author and no comments.", + time = System.currentTimeMillis() - 999_000 + ) + BlogPostView(post, {}) + val commentPost = getRandomBlogCommentItem( + post = post, + comment = "This is a comment on that first blog post.\n\nIt has two lines as well.", + time = System.currentTimeMillis() - 500_000 + ) + BlogPostView(commentPost, {}) + BlogPostView( + getRandomBlogCommentItem( + post = commentPost, + comment = "This is a second comment on that first blog post. It has only one line, but a long one.", + time = System.currentTimeMillis() + ), + null, + ) + BlogPostView(getRandomBlogPost(getRandomString(1337), 1337), null) + } +} + +@Composable +fun BlogPostView( + item: BlogPost, + onItemRepeat: ((BlogPost) -> Unit)?, + modifier: Modifier = Modifier, +) = Card(modifier = modifier) { + Column { + BlogPostViewHeader(item, onItemRepeat, Modifier.padding(8.dp)) + // should be changed back to verticalArrangement = spacedBy(8.dp) on the containing Column + // when https://github.com/JetBrains/compose-jb/issues/2729 is fixed + Spacer(Modifier.height(8.dp)) + SelectionContainer { + Text( + modifier = Modifier.padding(horizontal = 8.dp).fillMaxWidth(), + text = item.text ?: "", + ) + } + Spacer(Modifier.height(8.dp)) + // if no preview and a comment item, show comments + if (onItemRepeat != null && item is BlogCommentItem) { + item.comments.forEach { commentItem -> + BlogCommentView(commentItem, modifier = Modifier.padding(vertical = 4.dp)) + } + } + } +} + +@Composable +private fun BlogPostViewHeader( + item: BlogPost, + onItemRepeat: ((BlogPost) -> Unit)?, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + verticalArrangement = spacedBy(8.dp), + modifier = Modifier.weight(1f) + ) { + RepeatAuthorView(item) + if (item is BlogCommentItem) { + val postHeader = item.postHeader + AuthorView(postHeader.author, postHeader.authorInfo, postHeader.timestamp) + } + } + if (onItemRepeat != null) IconButton(onClick = { onItemRepeat(item) }) { + Icon( + imageVector = Icons.Default.Repeat, + contentDescription = i18n("access.blogs.reblog"), + ) + } + } +} + +@Composable +private fun RepeatAuthorView(item: BlogPost, modifier: Modifier = Modifier) { + val author = item.author + val authorInfo = item.authorInfo + val timestamp = item.timestamp + Row( + horizontalArrangement = spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + contentAlignment = BottomEnd, + modifier = Modifier.size(36.dp), + ) { + ProfileCircle(36.dp, author.id, authorInfo) + if (item is BlogCommentItem) { + Icon( + imageVector = Icons.Default.Repeat, + tint = Color.Black, + contentDescription = i18n("access.blogs.reblog"), + modifier = Modifier.size(16.dp).clip(CircleShape) + .border(1.dp, Color.Black, CircleShape).background(Color.White).padding(2.dp) + ) + } + } + Text( + modifier = Modifier.weight(1f, fill = false), + text = UiUtils.getContactDisplayName(author.name, authorInfo.alias), + fontWeight = if (authorInfo.status == OURSELVES) FontWeight.Bold else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + TrustIndicatorShort(authorInfo.status) + } + Text( + text = TimeUtils.getFormattedTimestamp(timestamp), + textAlign = TextAlign.End, + style = MaterialTheme.typography.caption, + maxLines = 1, + ) + } +} + +internal fun getRandomBlogPostItem(text: String, time: Long) = BlogPostItem( + header = BlogPostHeader( + MessageType.POST, + GroupId(getRandomId()), + MessageId(getRandomId()), + time, + System.currentTimeMillis(), + getRandomAuthor(), + AuthorInfo(AuthorInfo.Status.values().filter { it != AuthorInfo.Status.NONE }.random()), + Random.nextBoolean() && Random.nextBoolean() && Random.nextBoolean(), + Random.nextBoolean(), + ), + text = text, +) + +@Composable +private fun BlogCommentView(header: BlogCommentHeader, modifier: Modifier = Modifier) { + val comment = header.comment + if (comment != null) Column( + verticalArrangement = spacedBy(8.dp), + modifier = modifier, + ) { + HorizontalDivider() + AuthorView(header.author, header.authorInfo, header.timestamp, Modifier.padding(horizontal = 16.dp)) + SelectionContainer { + Text( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + text = comment, + ) + } + } +} + +internal fun getRandomBlogCommentItem(post: BlogPost, comment: String?, time: Long) = BlogCommentItem( + header = BlogCommentHeader( + MessageType.COMMENT, + GroupId(getRandomId()), + comment, + if (post is BlogCommentItem) post.header else post.postHeader, + MessageId(getRandomId()), + time, + System.currentTimeMillis(), + getRandomAuthor(), + AuthorInfo(AuthorInfo.Status.values().filter { it != AuthorInfo.Status.NONE }.random()), + Random.nextBoolean(), + ), + postHeader = post.postHeader, + text = comment, +) + +internal fun getRandomBlogPost(text: String, time: Long): BlogPost { + val postItem = getRandomBlogPostItem(text, time - 999_000) + return if (Random.nextBoolean()) { + postItem + } else { + val comment = if (Random.nextBoolean()) null else text + getRandomBlogCommentItem(postItem, comment, time) + } +} diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogScreen.kt new file mode 100644 index 0000000000000000000000000000000000000000..657b084b141cbad079ad1f8b7c738ff3f60de074 --- /dev/null +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogScreen.kt @@ -0,0 +1,65 @@ +/* + * Briar Desktop + * Copyright (C) 2023 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.blog + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.Center +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n +import org.briarproject.briar.desktop.viewmodel.viewModel + +@Composable +fun BlogScreen(viewModel: FeedViewModel = viewModel()) { + Scaffold( + content = { padding -> + if (viewModel.isLoading.value) { + Box( + contentAlignment = Center, + modifier = Modifier.padding(padding).fillMaxSize() + ) { + CircularProgressIndicator(Modifier.padding(16.dp)) + } + } else { + if (viewModel.posts.isEmpty()) { + Box( + contentAlignment = Center, + modifier = Modifier.padding(padding).fillMaxSize() + ) { + Text(i18n("blog.empty.state"), Modifier.padding(16.dp)) + } + } else { + FeedScreen(viewModel.posts, viewModel::selectPost, Modifier.padding(padding)) + } + } + }, + bottomBar = { + val onCloseReply = { viewModel.selectPost(null) } + BlogInput(viewModel.selectedPost.value, onCloseReply) { text -> + viewModel.createBlogPost(text) + } + } + ) +} diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/FeedScreen.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/FeedScreen.kt new file mode 100644 index 0000000000000000000000000000000000000000..5aa585e8baa906c036273329abe9614162a6e4d2 --- /dev/null +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/FeedScreen.kt @@ -0,0 +1,108 @@ +/* + * Briar Desktop + * Copyright (C) 2023 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.blog + +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.selection.selectableGroup +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 kotlinx.coroutines.delay +import org.briarproject.bramble.api.sync.MessageId +import org.briarproject.briar.desktop.conversation.reallyVisibleItemsInfo +import org.briarproject.briar.desktop.ui.Constants +import org.briarproject.briar.desktop.ui.isWindowFocused +import org.briarproject.briar.desktop.utils.PreviewUtils.preview +import java.time.Instant + +@Suppress("HardCodedStringLiteral") +fun main() = preview( + "text" to "This is a test blog post!\n\nThis is a blog post! This is a blog post! This is a blog post!", + "timestamp" to Instant.now().toEpochMilli(), + "numPosts" to 42, +) { + val items = buildList { + for (i in 1..getIntParameter("numPosts")) { + add(getRandomBlogPost(getStringParameter("text"), getLongParameter("timestamp"))) + } + } + FeedScreen(items, {}) +} + +@Composable +fun FeedScreen(posts: List<BlogPost>, onItemSelected: (BlogPost) -> Unit, modifier: Modifier = Modifier) { + val scrollState = rememberLazyListState() + // scroll to first unread item if needed + val lastUnreadIndex = posts.indexOfLast { item -> !item.isRead } + if (lastUnreadIndex > -1) LaunchedEffect(posts) { + scrollState.scrollToItem(lastUnreadIndex, -50) + } + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + state = scrollState, + modifier = Modifier.padding(end = 8.dp).selectableGroup() + ) { + items( + items = posts, + key = { item -> item.id }, + ) { item -> + BlogPostView( + item = item, + onItemRepeat = onItemSelected, + modifier = Modifier + .heightIn(min = Constants.HEADER_SIZE) + .fillMaxWidth() + .padding(vertical = 8.dp) + .padding(start = 16.dp, end = 8.dp) + ) + } + } + VerticalScrollbar( + adapter = rememberScrollbarAdapter(scrollState), + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight() + ) + if (isWindowFocused()) { + // if Briar Desktop currently has focus, + // mark all posts visible on the screen as read after some delay + LaunchedEffect( + posts, + scrollState.firstVisibleItemIndex, + scrollState.firstVisibleItemScrollOffset + ) { + delay(2_500) + val visibleMessageIds = scrollState.layoutInfo.reallyVisibleItemsInfo.map { + it.key as MessageId + } + // TODO onBlogPostsVisible(visibleMessageIds) + } + } + } +} diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/FeedViewModel.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/FeedViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..16a759336af5eaf368e8ff0ebb735e62208f5daa --- /dev/null +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/FeedViewModel.kt @@ -0,0 +1,179 @@ +/* + * Briar Desktop + * Copyright (C) 2021-2023 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.blog + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import mu.KotlinLogging +import org.briarproject.bramble.api.db.DatabaseExecutor +import org.briarproject.bramble.api.db.Transaction +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.identity.IdentityManager +import org.briarproject.bramble.api.lifecycle.LifecycleManager +import org.briarproject.bramble.api.sync.GroupId +import org.briarproject.bramble.api.sync.MessageId +import org.briarproject.bramble.api.sync.event.GroupRemovedEvent +import org.briarproject.briar.api.blog.BlogCommentHeader +import org.briarproject.briar.api.blog.BlogManager +import org.briarproject.briar.api.blog.BlogPostFactory +import org.briarproject.briar.api.blog.BlogPostHeader +import org.briarproject.briar.api.blog.event.BlogPostAddedEvent +import org.briarproject.briar.desktop.threading.BriarExecutors +import org.briarproject.briar.desktop.threading.UiExecutor +import org.briarproject.briar.desktop.viewmodel.EventListenerDbViewModel +import org.briarproject.briar.desktop.viewmodel.asList +import org.briarproject.briar.desktop.viewmodel.asState +import org.briarproject.briar.util.HtmlUtils +import javax.inject.Inject + +class FeedViewModel @Inject constructor( + private val blogManager: BlogManager, + private val blogPostFactory: BlogPostFactory, + private val identityManager: IdentityManager, + briarExecutors: BriarExecutors, + lifecycleManager: LifecycleManager, + db: TransactionManager, + eventBus: EventBus, +) : EventListenerDbViewModel(briarExecutors, lifecycleManager, db, eventBus) { + + companion object { + private val LOG = KotlinLogging.logger {} + } + + private val _isLoading = mutableStateOf(true) + val isLoading = _isLoading.asState() + + private val _posts = mutableStateListOf<BlogPost>() + val posts = _posts.asList() + + private val _selectedPost = mutableStateOf<BlogPost?>(null) + val selectedPost = _selectedPost.asState() + + init { + runOnDbThreadWithTransaction(true, this::loadAllBlogPosts) + } + + @Suppress("HardCodedStringLiteral") + override fun eventOccurred(e: Event) { + if (e is BlogPostAddedEvent) { + LOG.info("Blog post added") + onBlogPostAdded(e.header, e.isLocal) + } else if (e is GroupRemovedEvent && e.group.clientId == BlogManager.CLIENT_ID) { + LOG.info("Blog removed") + onBlogRemoved(e.group.id) + } + } + + @DatabaseExecutor + private fun loadAllBlogPosts(txn: Transaction) { + val posts = blogManager.getBlogIds(txn).flatMap { g -> + loadBlogPosts(txn, g) + }.sortedByDescending { blogPost -> + blogPost.header.timeReceived + } + txn.attach { + _posts.addAll(posts) + _isLoading.value = false + } + } + + @DatabaseExecutor + private fun loadBlogPosts(txn: Transaction, groupId: GroupId): List<BlogPost> { + return blogManager.getPostHeaders(txn, groupId).map { h -> + getItem(txn, h) + } + } + + @UiExecutor + private fun onBlogPostAdded(header: BlogPostHeader, local: Boolean) { + runOnDbThreadWithTransaction(true) { txn -> + val item = getItem(txn, header) + + LOG.error { "${item::class.java} ${item.text}" } + + txn.attach { + _posts.add(item) + _posts.sortByDescending { blogPost -> + blogPost.header.timeReceived + } + _isLoading.value = false + } + } + } + + @UiExecutor + private fun onBlogRemoved(id: GroupId) { + _posts.removeIf { it.id == id } + } + + @UiExecutor + fun selectPost(item: BlogPost?) { + _selectedPost.value = item + if (item != null && !item.isRead) markPostsRead(listOf(item.id)) + } + + @UiExecutor + fun createBlogPost(text: String) { + val parentPost = selectedPost.value + runOnDbThread { + val author = identityManager.localAuthor + val blog = blogManager.getPersonalBlog(author) + if (parentPost == null) { + val p = blogPostFactory.createBlogPost( + blog.id, + System.currentTimeMillis(), + null, + author, + text, + ) + blogManager.addLocalPost(p) + } else { + val comment = text.takeIf { it.isNotBlank() } + blogManager.addLocalComment(author, blog.id, comment, parentPost.header) + } + } + _selectedPost.value = null + } + + @UiExecutor + fun markPostsRead(postIds: List<MessageId>) { + runOnDbThread { + postIds.forEach { id -> + blogManager.setReadFlag(id, true) + } + } + } + + @DatabaseExecutor + private fun getItem(txn: Transaction, header: BlogPostHeader): BlogPost { + return if (header is BlogCommentHeader) { + val postHeader = BlogCommentItem.getBlogPostHeader(header) + BlogCommentItem(header, postHeader, getPostText(txn, postHeader.id)) + } else { + BlogPostItem(header, getPostText(txn, header.id)) + } + } + + @DatabaseExecutor + private fun getPostText(txn: Transaction, m: MessageId): String { + return HtmlUtils.cleanArticle(blogManager.getPostText(txn, m)) + } +} diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/AuthorView.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/AuthorView.kt index 0e213b2bc2f8d316448648e409bd35d384cee4b1..c395f0d79f1b6cc7fb1f2bac970b2877f25920cc 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/AuthorView.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/AuthorView.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight.Companion.Bold import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.briarproject.bramble.api.identity.Author import org.briarproject.briar.api.identity.AuthorInfo @@ -37,17 +38,24 @@ import org.briarproject.briar.desktop.utils.TimeUtils import org.briarproject.briar.desktop.utils.UiUtils.getContactDisplayName @Composable -fun AuthorView(author: Author, authorInfo: AuthorInfo, timestamp: Long, modifier: Modifier = Modifier) { +fun AuthorView( + author: Author, + authorInfo: AuthorInfo, + timestamp: Long, + modifier: Modifier = Modifier, + avatarSize: Dp = 27.dp, +) { Row( modifier = modifier, horizontalArrangement = spacedBy(8.dp), + verticalAlignment = CenterVertically, ) { Row( modifier = Modifier.weight(1f), horizontalArrangement = spacedBy(8.dp), verticalAlignment = CenterVertically, ) { - ProfileCircle(27.dp, author.id, authorInfo) + ProfileCircle(avatarSize, author.id, authorInfo) Text( modifier = Modifier.weight(1f, fill = false), text = getContactDisplayName(author.name, authorInfo.alias), 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 b1b37c56c4631f7de11b4caab5546c5193a2f7db..549f6b401c4403278b79e2ec668b8589aeffe3f2 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 @@ -27,6 +27,7 @@ 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.blog.BlogScreen import org.briarproject.briar.desktop.conversation.PrivateMessageScreen import org.briarproject.briar.desktop.forum.ForumScreen import org.briarproject.briar.desktop.mailbox.MailboxScreen @@ -64,6 +65,7 @@ fun MainScreen(viewModel: SidebarViewModel = viewModel()) { UiMode.CONTACTS -> PrivateMessageScreen() UiMode.GROUPS -> PrivateGroupScreen() UiMode.FORUMS -> ForumScreen() + UiMode.BLOGS -> BlogScreen() UiMode.MAILBOX -> MailboxScreen() UiMode.SETTINGS -> SettingsScreen() UiMode.ABOUT -> AboutScreen() 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 aee751faa6747bd25f232a1aa21ca6422da90383..c8cb981890f07b28479bd1412dbacc11ca79caf5 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 @@ -22,6 +22,7 @@ import dagger.Binds import dagger.MapKey import dagger.Module import dagger.multibindings.IntoMap +import org.briarproject.briar.desktop.blog.FeedViewModel import org.briarproject.briar.desktop.contact.ContactListViewModel import org.briarproject.briar.desktop.contact.add.remote.AddContactViewModel import org.briarproject.briar.desktop.conversation.ConversationViewModel @@ -91,6 +92,11 @@ abstract class ViewModelModule { @ViewModelKey(PrivateGroupSharingViewModel::class) abstract fun bindPrivateGroupSharingViewModel(privateGroupSharingViewModel: PrivateGroupSharingViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(FeedViewModel::class) + abstract fun bindFeedViewModel(feedViewModel: FeedViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(MailboxViewModel::class) diff --git a/briar-desktop/src/main/resources/strings/BriarDesktop.properties b/briar-desktop/src/main/resources/strings/BriarDesktop.properties index fc63b461da0f3fb5d74e16869e575e5692d2a4f4..3ca62d2bb7c139ed7bb44bae3409b2833fe22408 100644 --- a/briar-desktop/src/main/resources/strings/BriarDesktop.properties +++ b/briar-desktop/src/main/resources/strings/BriarDesktop.properties @@ -99,6 +99,10 @@ access.group.last_post_timestamp=last message: {0} access.group.jump_to_prev_unread=Jump to previous unread message access.group.jump_to_next_unread=Jump to next unread message +access.blogs.list=blog feed +access.blogs.reply.close=Close comment +access.blogs.reblog=Reblog + # Contacts contacts.none_selected.title=No contact selected contacts.none_selected.hint=Select a contact to start chatting @@ -212,6 +216,12 @@ group.invite.action.title=Invite Contacts group.invite.action.no_contacts=No contacts yet. You can only invite contacts to your private group. group.mark.read=Mark all as read +# Blogs +blog.empty.state=No posts to show +blog.post.reply.intro=Reblog this post: +blog.post.hint=Type your blog post +blog.post.reply.hint=Add a comment (optional) + # Introduction introduction.introduce=Make Introduction introduction.message=Add a message (optional)