core: render web previews for channels (#7029)

* plan: web previews for channels

* types for recipient side to support channel web previews and domain names

* fix

* migrations

* update schema and api types

* update schema

* rename migrations

* core: render channel preview data

* core: render channel preview data in relays

* website: use cpp to inject JS functions

* JSC files

* remove directory.js

* channel preview renderer

* Revert "cli: fix redraw slowness (#6735)"

This reverts commit b801d77c74.

* sample channel page

* default avatar

* rename options

* better layout

* layout

* images

* some fixes

* tails

* markdown colors

* image sizes

* reactions

* fix reactions

* fewer avatars

* forward icon

* command to change group access parameters

* view public group access changes in CLI

* media metadata color

* ios: group web access ui

* update ui

* add init

* kotlin, labels

* update page

* update relay base URL

* fix

* ios update channel web page info

* update kotlin layout

* use cards

* update layout

* use domains for relay data, path is fixed

* update embed code

* fix bots api

* include only history items and senders

* update preview JS/HTML

* show different error if link is different

* remove stale json files

* better layout

* layout fixes

* improve layout

* improve layout

* update embed code

* web cta

* better layout

* buttons

* layout

* paddings

* desktop cta

* desktop cta

* cta layout

* fonts

* paddings

* paddings

* more paddings

* copy link

* read more

* hide avatar and placeholder when all messages are from channel

* color scheme

* fix color

* improve

* layout

* welcome message

* dark mode colors

* padding

* font size

* overscroll

* font

* logo on button

* better join

* buttons

* refactor

* another logo

* text

* desktop button

* button text

* center

* fix svg

* padding

* smaller gap

* render channel on any message changes etc

* fixes

* atomic file updates, escape attributes

* fix tests

* more tests

* more efficient rendering

* improve security

* sanitize links, include mentioned members

* schema

* fixes

* improve rendering

* fix showing correct subscribers count

* fix member names

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-06-16 14:36:55 +01:00
committed by GitHub
co-authored by Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
parent 43904dd0dc
commit adb3fb8cb2
42 changed files with 3234 additions and 175 deletions
@@ -0,0 +1,186 @@
package chat.simplex.common.views.chat.group
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
@Composable
fun ChannelWebPageView(
rhId: Long?,
groupInfo: GroupInfo,
chatModel: ChatModel,
close: () -> Unit
) {
val isChannel = groupInfo.isChannel
val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess
val webPage = rememberSaveable { mutableStateOf(access?.groupWebPage ?: "") }
val allowEmbedding = rememberSaveable { mutableStateOf(access?.allowEmbedding ?: false) }
val groupRelays = remember { mutableStateListOf<GroupRelay>() }
val dataUnchanged = webPage.value.trim() == (access?.groupWebPage ?: "") &&
allowEmbedding.value == (access?.allowEmbedding ?: false)
val save: () -> Unit = {
withBGApi {
val trimmedPage = webPage.value.trim()
val newAccess = PublicGroupAccess(
groupWebPage = trimmedPage.ifEmpty { null },
groupDomain = access?.groupDomain,
domainWebPage = access?.domainWebPage ?: false,
allowEmbedding = allowEmbedding.value
)
val gp = groupInfo.groupProfile.copy(
publicGroup = groupInfo.groupProfile.publicGroup?.copy(publicGroupAccess = newAccess)
)
val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, gp, isChannel)
if (gInfo != null) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateGroup(rhId, gInfo)
}
close()
}
}
}
val closeWithAlert = {
if (dataUnchanged) {
close()
} else {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(MR.strings.save_preferences_question),
confirmText = generalGetString(if (isChannel) MR.strings.save_and_notify_channel_subscribers else MR.strings.save_and_notify_group_members),
dismissText = generalGetString(MR.strings.exit_without_saving),
onConfirm = save,
onDismiss = close,
)
}
}
LaunchedEffect(Unit) {
val relays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId)
groupRelays.clear()
groupRelays.addAll(relays)
}
BackHandler(onBack = closeWithAlert)
ModalView(close = closeWithAlert, cardScreen = true) {
ChannelWebPageLayout(
isChannel = isChannel,
webPage = webPage,
allowEmbedding = allowEmbedding,
groupRelays = groupRelays,
groupInfo = groupInfo,
dataUnchanged = dataUnchanged,
save = save
)
}
}
@Composable
private fun ChannelWebPageLayout(
isChannel: Boolean,
webPage: MutableState<String>,
allowEmbedding: MutableState<Boolean>,
groupRelays: List<GroupRelay>,
groupInfo: GroupInfo,
dataUnchanged: Boolean,
save: () -> Unit
) {
val clipboard = LocalClipboardManager.current
ColumnWithScrollBar {
AppBarTitle(stringResource(if (isChannel) MR.strings.channel_webpage else MR.strings.group_webpage))
val embedCode = embedCode(groupRelays, groupInfo)
if (embedCode != null) {
SectionTextFooter(stringResource(MR.strings.webpage_info))
SectionDividerSpaced()
SectionView(stringResource(MR.strings.webpage_code)) {
SectionItemView {
Text(
embedCode,
style = MaterialTheme.typography.body2.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp),
maxLines = 6,
overflow = TextOverflow.Ellipsis
)
}
SectionItemView({
clipboard.setText(AnnotatedString(embedCode))
showToast(generalGetString(MR.strings.copied))
}) {
Icon(painterResource(MR.images.ic_content_copy), null, tint = MaterialTheme.colors.primary)
Spacer(Modifier.width(8.dp))
Text(stringResource(MR.strings.copy_code), color = MaterialTheme.colors.primary)
}
}
SectionTextFooter(stringResource(MR.strings.webpage_code_footer))
} else {
SectionTextFooter(stringResource(MR.strings.relays_no_web_support))
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.enter_webpage_url)) {
PlainTextEditor(webPage, placeholder = stringResource(MR.strings.web_page_url_placeholder))
}
SectionTextFooter(stringResource(MR.strings.webpage_url_footer))
SectionDividerSpaced()
SectionView {
PreferenceToggle(stringResource(MR.strings.allow_anyone_to_embed), checked = allowEmbedding.value) {
allowEmbedding.value = it
}
}
SectionTextFooter(stringResource(if (allowEmbedding.value) MR.strings.embed_any_webpage_can_show else MR.strings.embed_only_your_page))
SectionDividerSpaced()
SectionView {
SectionItemView(save, disabled = dataUnchanged) {
Text(
stringResource(MR.strings.save_verb),
color = if (dataUnchanged) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
}
SectionBottomSpacer()
}
}
private fun embedCode(groupRelays: List<GroupRelay>, groupInfo: GroupInfo): String? {
val pg = groupInfo.groupProfile.publicGroup ?: return null
val relayDomains = groupRelays.mapNotNull { it.relayCap.webDomain }
if (relayDomains.isEmpty()) return null
val domains = relayDomains.joinToString(",")
return """<div data-simplex-channel-preview
data-channel-link="${pg.groupLink}"
data-channel-id="${pg.publicGroupId}"
data-relay-domains="$domains"
data-app-download-buttons="on"
data-color-scheme="light"
></div>
<script src="https://simplex.chat/js/channel-preview.js"></script>"""
}
@@ -175,6 +175,9 @@ fun ModalData.GroupChatInfoView(
manageGroupLink = {
ModalManager.end.showModal(cardScreen = true) { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) }
},
manageWebPage = {
ModalManager.end.showCustomModal { close -> ChannelWebPageView(rhId, groupInfo, chatModel, close) }
},
onSearchClicked = onSearchClicked,
deletingItems = deletingItems
)
@@ -506,6 +509,7 @@ fun ModalData.GroupChatInfoLayout(
clearChat: () -> Unit,
leaveGroup: () -> Unit,
manageGroupLink: () -> Unit,
manageWebPage: () -> Unit,
close: () -> Unit = { ModalManager.closeAllModalsEverywhere()},
onSearchClicked: () -> Unit,
deletingItems: State<Boolean>
@@ -796,6 +800,13 @@ fun ModalData.GroupChatInfoLayout(
}
}
if (groupInfo.useRelays && groupInfo.isOwner) {
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.advanced_options)) {
ChannelWebPageButton(groupInfo, manageWebPage)
}
}
if (developerTools) {
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.section_title_for_console)) {
@@ -1209,6 +1220,16 @@ private fun ChannelLinkButton(onClick: () -> Unit) {
)
}
@Composable
private fun ChannelWebPageButton(groupInfo: GroupInfo, onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_travel_explore),
stringResource(if (groupInfo.isChannel) MR.strings.channel_webpage else MR.strings.group_webpage),
onClick,
iconColor = MaterialTheme.colors.secondary
)
}
@Composable
private fun ChannelLinkQRCodeSection(groupLink: String) {
val clipboard = LocalClipboardManager.current
@@ -1413,6 +1434,7 @@ fun PreviewGroupChatInfoLayout() {
clearChat = {},
leaveGroup = {},
manageGroupLink = {},
manageWebPage = {},
onSearchClicked = {},
deletingItems = remember { mutableStateOf(true) }
)
@@ -102,6 +102,28 @@ fun TextEditor(
}
}
@Composable
fun PlainTextEditor(
value: MutableState<String>,
placeholder: String? = null,
singleLine: Boolean = true
) {
BasicTextField(
value = value.value,
onValueChange = { value.value = it },
modifier = Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = 12.dp),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
singleLine = singleLine,
cursorBrush = SolidColor(MaterialTheme.colors.secondary),
decorationBox = { innerTextField ->
if (value.value.isEmpty() && placeholder != null) {
Text(placeholder, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
}
innerTextField()
}
)
}
@Serializable
data class ParsedFormattedText(
val formattedText: List<FormattedText>? = null
@@ -1925,6 +1925,20 @@
<string name="button_welcome_message">Welcome message</string>
<string name="group_link">Group link</string>
<string name="channel_link">Channel link</string>
<string name="channel_webpage">Channel webpage</string>
<string name="group_webpage">Group webpage</string>
<string name="advanced_options">Advanced options</string>
<string name="web_page_url_placeholder">https://</string>
<string name="allow_anyone_to_embed">Allow anyone to embed</string>
<string name="enter_webpage_url">Enter webpage URL</string>
<string name="webpage_url_footer">It will be shown to subscribers and used to allow loading the preview.</string>
<string name="webpage_code">Webpage code</string>
<string name="webpage_code_footer">Add this code to your webpage. It will display the preview of your channel / group.</string>
<string name="copy_code">Copy code</string>
<string name="webpage_info">Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting.</string>
<string name="relays_no_web_support">Used chat relays do not support webpages.</string>
<string name="embed_any_webpage_can_show">Any webpage can show the preview.</string>
<string name="embed_only_your_page">Only your page above can show the preview.</string>
<string name="create_group_link">Create group link</string>
<string name="button_create_group_link">Create link</string>
<string name="delete_link_question">Delete link?</string>