diff --git a/.gitignore b/.gitignore index 7bd3d04e59..035d24c6cd 100644 --- a/.gitignore +++ b/.gitignore @@ -54,7 +54,10 @@ website/translations.json website/src/img/images/ website/src/images/ website/src/js/lottie.min.js -website/src/js/ethers* +website/src/js/ethers.* +website/src/js/directory.js +website/src/js/channel-preview.js +website/src/js/simplex-lib.js website/src/file-assets/ website/src/link-images/ website/src/privacy.md diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift new file mode 100644 index 0000000000..dd46b7a117 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift @@ -0,0 +1,169 @@ +// +// ChannelWebAccessView.swift +// SimpleX (iOS) +// +// Created by simplex.chat on 31/05/2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChannelWebAccessView: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.dismiss) var dismiss: DismissAction + @Binding var groupInfo: GroupInfo + @State private var webPage: String + @State private var allowEmbedding: Bool + @State private var saving = false + @State private var groupRelays: [GroupRelay] = [] + + init(groupInfo: Binding) { + _groupInfo = groupInfo + let access = groupInfo.wrappedValue.groupProfile.publicGroup?.publicGroupAccess + _webPage = State(initialValue: access?.groupWebPage ?? "") + _allowEmbedding = State(initialValue: access?.allowEmbedding ?? false) + } + + var body: some View { + List { + if let code = embedCode { + webpageInfo("Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting.") + + Section { + ScrollView { + Text(code) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + .frame(maxHeight: 88) + Button { + UIPasteboard.general.string = code + } label: { + Label("Copy code", systemImage: "doc.on.doc") + } + } header: { + Text("Webpage code") + } footer: { + Text("Add this code to your webpage. It will display the preview of your channel / group.") + } + } else { + webpageInfo("Used chat relays do not support webpages.") + } + + Section { + TextField("https://", text: $webPage) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + } header: { + Text("Enter webpage URL") + } footer: { + Text("It will be shown to subscribers and used to allow loading the preview.") + } + + Section { + Toggle("Allow anyone to embed", isOn: $allowEmbedding) + } footer: { + Text(allowEmbedding ? "Any webpage can show the preview." : "Only your page above can show the preview.") + } + + Section { + Button { + saveAccess() + } label: { + HStack { + Text(groupInfo.isChannel ? "Save and notify subscribers" : "Save and notify members") + if saving { Spacer(); ProgressView() } + } + } + .disabled(!hasChanges || saving) + } + } + .modifier(ThemedBackground(grouped: true)) + .onAppear { + Task { + let relays = await apiGetGroupRelays(groupInfo.groupId) + await MainActor.run { groupRelays = relays } + } + } + .onDisappear { + if hasChanges { + showAlert( + title: NSLocalizedString("Save webpage settings?", comment: "alert title"), + message: NSLocalizedString("Webpage settings were changed. If you save, the updated settings will be sent to subscribers.", comment: "alert message"), + buttonTitle: NSLocalizedString("Save", comment: "alert button"), + buttonAction: saveAccess, + cancelButton: true + ) + } + } + } + + private func webpageInfo(_ text: LocalizedStringKey) -> some View { + Section { + Text(text).foregroundColor(theme.colors.secondary) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 0, trailing: 16)) + } + + private var hasChanges: Bool { + let access = groupInfo.groupProfile.publicGroup?.publicGroupAccess + let currentWebPage = access?.groupWebPage ?? "" + let currentEmbedding = access?.allowEmbedding ?? false + return webPage != currentWebPage || allowEmbedding != currentEmbedding + } + + private var relayDomains: [String] { + groupRelays.compactMap { $0.relayCap.webDomain } + } + + private var embedCode: String? { + if let pg = groupInfo.groupProfile.publicGroup, + !relayDomains.isEmpty { + """ +
+ + """ + } else { + nil + } + } + + private func saveAccess() { + saving = true + Task { + do { + var gp = groupInfo.groupProfile + if var pg = gp.publicGroup { + let trimmedPage = webPage.trimmingCharacters(in: .whitespacesAndNewlines) + let existingAccess = pg.publicGroupAccess + pg.publicGroupAccess = PublicGroupAccess( + groupWebPage: trimmedPage.isEmpty ? nil : trimmedPage, + groupDomain: existingAccess?.groupDomain, + domainWebPage: existingAccess?.domainWebPage ?? false, + allowEmbedding: allowEmbedding + ) + gp.publicGroup = pg + } + let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp) + await MainActor.run { + groupInfo = gInfo + ChatModel.shared.updateGroup(gInfo) + saving = false + } + } catch { + logger.error("ChannelWebAccessView apiUpdateGroup error: \(responseError(error))") + await MainActor.run { saving = false } + } + } + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 26aedb2541..b62939fd36 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -244,6 +244,12 @@ struct GroupChatInfoView: View { } } + if groupInfo.useRelays && groupInfo.isOwner { + Section(header: Text("Advanced options").foregroundColor(theme.colors.secondary)) { + channelWebAccessButton() + } + } + if developerTools { Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { infoRow("Local name", chat.chatInfo.localDisplayName) @@ -657,6 +663,17 @@ struct GroupChatInfoView: View { } } + private func channelWebAccessButton() -> some View { + let title: LocalizedStringKey = groupInfo.isChannel ? "Channel webpage" : "Group webpage" + return NavigationLink { + ChannelWebAccessView(groupInfo: $groupInfo) + .navigationBarTitle(title) + .navigationBarTitleDisplayMode(.large) + } label: { + Label(title, systemImage: "globe") + } + } + private func groupLinkDestinationView() -> some View { GroupLinkView( groupId: groupInfo.groupId, diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 20dfbba6ee..c5dc039d8d 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -170,6 +170,7 @@ 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; }; 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; }; + E5E418022F83D2CA00252B9E /* ChannelWebAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */; }; 6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7072F48D0000060512B /* AddGroupRelayView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -549,6 +550,7 @@ 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = ""; }; 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = ""; }; + E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelWebAccessView.swift; sourceTree = ""; }; 6495D7072F48D0000060512B /* AddGroupRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupRelayView.swift; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -1178,6 +1180,7 @@ 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */, 6495D7032F48CFC50060512B /* ChannelMembersView.swift */, 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */, + E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */, 6495D7072F48D0000060512B /* AddGroupRelayView.swift */, ); path = Group; @@ -1640,6 +1643,7 @@ 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */, 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */, 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */, + E5E418022F83D2CA00252B9E /* ChannelWebAccessView.swift in Sources */, 6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */, 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */, 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 6b757e795f..56f92cfc0b 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2614,6 +2614,13 @@ public enum GroupType: Codable, Hashable { } public struct PublicGroupAccess: Codable, Hashable { + public init(groupWebPage: String? = nil, groupDomain: String? = nil, domainWebPage: Bool = false, allowEmbedding: Bool = false) { + self.groupWebPage = groupWebPage + self.groupDomain = groupDomain + self.domainWebPage = domainWebPage + self.allowEmbedding = allowEmbedding + } + public var groupWebPage: String? public var groupDomain: String? public var domainWebPage: Bool = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt new file mode 100644 index 0000000000..98067e49dd --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt @@ -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() } + + 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, + allowEmbedding: MutableState, + groupRelays: List, + 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, 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 """
+""" +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 93c318cab5..b2c25bf06c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -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 @@ -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) } ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index e8070b5c76..cd40585cad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -102,6 +102,28 @@ fun TextEditor( } } +@Composable +fun PlainTextEditor( + value: MutableState, + 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? = null diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index e943e0080a..9630c61004 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1925,6 +1925,20 @@ Welcome message Group link Channel link + Channel webpage + Group webpage + Advanced options + https:// + Allow anyone to embed + Enter webpage URL + It will be shown to subscribers and used to allow loading the preview. + Webpage code + Add this code to your webpage. It will display the preview of your channel / group. + Copy code + Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting. + Used chat relays do not support webpages. + Any webpage can show the preview. + Only your page above can show the preview. Create group link Create link Delete link? diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 003969660b..8ebd510a55 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -281,6 +281,7 @@ cliCommands = "SetGroupTimedMessages", "SetLocalDeviceName", "SetProfileAddress", + "SetPublicGroupAccess", "SetSendReceipts", "SetShowMemberMessages", "SetShowMessages", diff --git a/simplex-chat.cabal b/simplex-chat.cabal index ae573936e7..86350acdd4 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -93,6 +93,7 @@ library Simplex.Chat.Types.Shared Simplex.Chat.Types.UITheme Simplex.Chat.Util + Simplex.Chat.Web if !flag(client_library) exposed-modules: Simplex.Chat.Bot @@ -141,6 +142,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at + Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain else exposed-modules: Simplex.Chat.Archive @@ -301,6 +303,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at + Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index af3f98d6a6..b795ba9b9c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -128,6 +128,7 @@ defaultChatConfig = highlyAvailable = False, deliveryWorkerDelay = 0, deliveryBucketSize = 10000, + webPreviewConfig = Nothing, channelSubscriberRole = GRObserver, relayChecksInterval = 15 * 60, -- 15 minutes relayInactiveTTL = nominalDay, @@ -152,11 +153,11 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, presetServers, inlineFiles, deviceNameForRemote, confirmMigrations} - ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} + ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, webPreviewConfig, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} backgroundMode = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations - config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'} + config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, webPreviewConfig, highlyAvailable, confirmMigrations = confirmMigrations'} randomPresetServers <- chooseRandomServers presetServers' let rndSrvs = L.toList randomPresetServers operatorWithId (i, op) = (\o -> o {operatorId = DBEntityId i}) <$> pOperator op @@ -194,6 +195,7 @@ newChatController deliveryJobWorkers <- TM.emptyIO relayRequestWorkers <- TM.emptyIO relayGroupLinkChecksAsync <- newTVarIO Nothing + webPreviewState <- forM webPreviewConfig $ \_ -> newWebPreviewState chatRelayTests <- TM.emptyIO expireCIThreads <- TM.emptyIO expireCIFlags <- TM.emptyIO @@ -238,6 +240,7 @@ newChatController deliveryJobWorkers, relayRequestWorkers, relayGroupLinkChecksAsync, + webPreviewState, chatRelayTests, expireCIThreads, expireCIFlags, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6a4545a380..48913af9a5 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -39,6 +39,7 @@ import Data.Char (ord) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) +import Data.Set (Set) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.String @@ -162,6 +163,7 @@ data ChatConfig = ChatConfig ciExpirationInterval :: Int64, -- microseconds deliveryWorkerDelay :: Int64, -- microseconds deliveryBucketSize :: Int, + webPreviewConfig :: Maybe WebPreviewConfig, channelSubscriberRole :: GroupMemberRole, -- TODO [relays] starting role should be communicated in protocol from owner to relays relayChecksInterval :: NominalDiffTime, relayInactiveTTL :: NominalDiffTime, @@ -173,6 +175,43 @@ data ChatConfig = ChatConfig chatHooks :: ChatHooks } +data WebPreviewConfig = WebPreviewConfig + { webDomain :: Text, + webJsonDir :: FilePath, + webCorsFile :: Maybe FilePath, + webUpdateInterval :: Int, -- seconds + webPreviewItemCount :: Int + } + +data PublishableGroup = PublishableGroup + { pgFileName :: FilePath, + pgCorsEntry :: Maybe (Text, CorsOrigin) + } + +data CorsOrigin = CorsAny | CorsOrigins [Text] + deriving (Show) + +data WebPreviewState = WebPreviewState + { publishableGroupIds :: TVar (Map Int64 PublishableGroup), + priorityRender :: TQueue Int64, + filesToRemove :: TQueue FilePath, + corsNeeded :: TVar Bool, + routinePending :: TVar (Set Int64), + wakeSignal :: TMVar (), + webPreviewWorkerAsync :: TVar (Maybe (Async ())) + } + +newWebPreviewState :: IO WebPreviewState +newWebPreviewState = do + publishableGroupIds <- newTVarIO mempty + priorityRender <- newTQueueIO + filesToRemove <- newTQueueIO + corsNeeded <- newTVarIO False + routinePending <- newTVarIO mempty + wakeSignal <- newEmptyTMVarIO + webPreviewWorkerAsync <- newTVarIO Nothing + pure WebPreviewState {publishableGroupIds, priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal, webPreviewWorkerAsync} + -- | Builds the read-only context threaded through store functions from chat config. -- The single construction point, so new store-wide config (e.g. server keys) is added in one place. mkStoreCxt :: ChatConfig -> StoreCxt @@ -266,6 +305,7 @@ data ChatController = ChatController deliveryJobWorkers :: TMap DeliveryWorkerKey Worker, relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework relayGroupLinkChecksAsync :: TVar (Maybe (Async ())), + webPreviewState :: Maybe WebPreviewState, chatRelayTests :: TMap ConnId RelayTest, expireCIThreads :: TMap UserId (Maybe (Async ())), expireCIFlags :: TMap UserId Bool, @@ -555,6 +595,7 @@ data ChatCommand | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) | ShowGroupDescription GroupName + | SetPublicGroupAccess GroupName PublicGroupAccess | CreateGroupLink GroupName GroupMemberRole | GroupLinkMemberRole GroupName GroupMemberRole | DeleteGroupLink GroupName diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 72cf6a412c..646377ac9c 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -90,6 +90,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Util (liftIOEither, zipWith3') import qualified Simplex.Chat.Util as U +import Simplex.Chat.Web (webPreviewWorker) import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) @@ -201,6 +202,7 @@ startChatController mainApp enableSndFiles = do startCleanupManager void $ forkIO $ mapM_ startExpireCIs users startRelayChecks users + startWebPreview users else when enableSndFiles $ startXFTP xftpStartSndWorkers pure a1 startXFTP startWorkers = do @@ -232,6 +234,20 @@ startChatController mainApp enableSndFiles = do a <- Just <$> async (void $ runExceptT $ runRelayGroupLinkChecks relayUser) atomically $ writeTVar relayAsync a _ -> pure () + startWebPreview users = do + let relayUsers = filter (\User {userChatRelay} -> isTrue userChatRelay) users + ChatConfig {webPreviewConfig = cfg_} <- asks config + case (relayUsers, cfg_) of + (_ : _, Just cfg) -> do + wps_ <- asks webPreviewState + forM_ wps_ $ \WebPreviewState {webPreviewWorkerAsync} -> + readTVarIO webPreviewWorkerAsync >>= \case + Nothing -> do + cc <- ask + a <- Just <$> async (liftIO $ webPreviewWorker cfg cc relayUsers) + atomically $ writeTVar webPreviewWorkerAsync a + _ -> pure () + _ -> pure () startExpireCIs user = whenM shouldExpireChats $ do startExpireCIThread user setExpireCIFlag user True @@ -3060,6 +3076,12 @@ processChatCommand cxt nm = \case updateGroupProfileByName gName $ \p -> p {description} ShowGroupDescription gName -> withUser $ \user -> CRGroupDescription user <$> withFastStore (\db -> getGroupInfoByName db cxt user gName) + SetPublicGroupAccess gName access -> withUser $ \user -> do + gInfo@GroupInfo {groupProfile = p@GroupProfile {publicGroup}} <- withStore $ \db -> + getGroupIdByName db user gName >>= getGroupInfo db cxt user + case publicGroup of + Just pg -> runUpdateGroupProfile user gInfo p {publicGroup = Just pg {publicGroupAccess = Just access}} + Nothing -> throwChatError $ CECommandError "not a public group" APICreateGroupLink groupId mRole -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do gInfo@GroupInfo {groupProfile} <- withFastStore $ \db -> getGroupInfo db cxt user groupId assertUserGroupRole gInfo GRAdmin @@ -4896,6 +4918,17 @@ runRelayGroupLinkChecks user = do else void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSActive RSInactive _ -> pure () _ -> pure () + sendRelayCapIfNeeded cxt gInfo + sendRelayCapIfNeeded cxt gInfo = do + ChatConfig {webPreviewConfig} <- asks config + let currentWebDomain = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig + sentWebDomain <- withStore' (`getRelaySentWebDomain` gInfo) + when (currentWebDomain /= sentWebDomain) $ do + owners <- withStore' $ \db -> getGroupOwners db cxt user gInfo + let capableOwners = filter (\m -> memberCurrent m && m `supportsVersion` relayWebCapVersion) owners + unless (null capableOwners) $ do + void $ sendGroupMessage' user gInfo capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) + withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain checkRelayInactiveGroups = do cxt <- chatStoreCxt ttl <- asks (relayInactiveTTL . config) @@ -5219,6 +5252,7 @@ chatCommandP = "/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP), ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayNameP <* A.space <*> groupProfile), ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayNameP), + "/public group access " *> char_ '#' *> (SetPublicGroupAccess <$> displayNameP <*> publicGroupAccessP), "/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> optional (A.space *> msgTextP)), "/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <* A.space <*> (Just <$> msgTextP)), "/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> pure Nothing), @@ -5425,6 +5459,12 @@ chatCommandP = clearOverrides <- (" clear_overrides=" *> onOffP) <|> pure False pure UserMsgReceiptSettings {enable, clearOverrides} onOffP = ("on" $> True) <|> ("off" $> False) + publicGroupAccessP = do + groupWebPage <- optional (" web=" *> (safeDecodeUtf8 <$> A.takeTill A.isSpace)) + groupDomain <- optional (" domain=" *> (safeDecodeUtf8 <$> A.takeTill A.isSpace)) + domainWebPage <- (" domain_page=" *> onOffP) <|> pure False + allowEmbedding <- (" embed=" *> onOffP) <|> pure False + pure PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding} profileNameDescr = (,) <$> displayNameP <*> shortDescrP -- 'Help with bot':'link ','Menu of commands':[...] botCommandsP :: Parser [ChatBotCommand] diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 0595cf7c4b..325e552d44 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1046,8 +1046,9 @@ acceptRelayJoinRequestAsync cReqInvId cReqChatVRange relayLink = do - -- TODO [channel web] derive RelayCapabilities from relay config (RelayWebOptions) - let msg = XGrpRelayAcpt relayLink defaultRelayCapabilities + ChatConfig {webPreviewConfig} <- asks config + let webDomain_ = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig + msg = XGrpRelayAcpt relayLink RelayCapabilities {webDomain = webDomain_} subMode <- chatReadVar subscriptionMode cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 9bb3d5d2eb..b948d7727b 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -47,6 +47,7 @@ import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery import Simplex.Chat.Library.Internal +import Simplex.Chat.Web (channelContentChanged, channelProfileUpdated, channelRemoved) import Simplex.Chat.Messages import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, batchProfiles, batchProfilesWithBody, encodeBinaryBatch, encodeFwdElement, maxBatchElementSize) import Simplex.Chat.Messages.CIContent @@ -870,6 +871,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = else pure gInfo pure (m {memberStatus = GSMemConnected}, gInfo') toView $ CEvtUserJoinedGroup user gInfo' m' + when (isRelay membership) $ do + cc <- ask + atomically $ channelProfileUpdated cc groupId groupProfile (gInfo'', m'', scopeInfo) <- mkGroupChatScope gInfo' m' -- Create e2ee, feature and group description chat items only on first connected relay ifM @@ -1018,6 +1022,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> VerifiedMsg e -> CM (Maybe NewMessageDeliveryTask) processEvent gInfo' m' verifiedMsg = do (m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg + cc <- ask let ctx js = DeliveryTaskContext js False checkSendAsGroup :: Maybe Bool -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext) checkSendAsGroup asGroup_ a @@ -1074,7 +1079,17 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = XInfoProbeOk probe -> Nothing <$ xInfoProbeOk (COMGroupMember m'') probe BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' sharedMsgId chunk msgMeta _ -> Nothing <$ messageError ("unsupported message: " <> tshow event) - forM deliveryTaskContext_ $ \taskContext -> + forM deliveryTaskContext_ $ \taskContext -> do + let contentChanged :: CM () + contentChanged = atomically $ channelContentChanged cc groupId + case event of + XMsgNew {} -> contentChanged + XMsgUpdate {} -> contentChanged + XMsgDel {} -> contentChanged + XMsgReact {} -> contentChanged + XGrpInfo p' -> atomically $ channelProfileUpdated cc groupId p' + XGrpDel {} -> atomically $ channelRemoved cc groupId + _ -> pure () pure $ NewMessageDeliveryTask {messageId = msgId, taskContext} checkSendRcpt :: [AParsedMsg] -> CM Bool checkSendRcpt aMsgs = do diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 6cf30edbd5..85074e93f4 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -261,6 +261,7 @@ mobileChatOpts dbOptions = tbqSize = 4096, deviceName = Nothing, chatRelay = False, + webPreviewConfig = Nothing, highlyAvailable = False, yesToUpMigrations = False, migrationBackupPath = Just "", diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 08a765077f..a936f58848 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -28,7 +28,7 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Numeric.Natural (Natural) import Options.Applicative -import Simplex.Chat.Controller (ChatLogLevel (..), SimpleNetCfg (..), updateStr, versionNumber, versionString) +import Simplex.Chat.Controller (ChatLogLevel (..), SimpleNetCfg (..), WebPreviewConfig (..), updateStr, versionNumber, versionString) import Simplex.FileTransfer.Description (mb) import Simplex.Messaging.Client (HostMode (..), SMPWebPortServers (..), SocksMode (..), textToHostMode) import Simplex.Messaging.Encoding.String @@ -66,6 +66,7 @@ data CoreChatOpts = CoreChatOpts tbqSize :: Natural, deviceName :: Maybe Text, chatRelay :: Bool, + webPreviewConfig :: Maybe WebPreviewConfig, highlyAvailable :: Bool, yesToUpMigrations :: Bool, migrationBackupPath :: Maybe FilePath, @@ -240,6 +241,46 @@ coreChatOptsP appDir defaultDbName = do ( long "relay" <> help "Run as a chat relay client" ) + webPreviewConfig <- do + webDomain_ <- + optional $ + strOption + ( long "relay-web-domain" + <> metavar "DOMAIN" + <> help "Domain for channel web previews (relay only)" + ) + webJsonDir_ <- + optional $ + strOption + ( long "relay-web-dir" + <> metavar "DIR" + <> help "Directory for channel web preview JSON files (relay only)" + ) + webCorsFile <- + optional $ + strOption + ( long "relay-web-cors-file" + <> metavar "FILE" + <> help "Path to generated Caddy CORS config file (relay only)" + ) + webUpdateInterval <- + option auto + ( long "relay-web-interval" + <> metavar "SECONDS" + <> help "Interval between web preview regeneration in seconds (relay only)" + <> value 300 + ) + webPreviewItemCount <- + option auto + ( long "relay-web-item-count" + <> metavar "COUNT" + <> help "Number of recent messages in channel web preview (relay only)" + <> value 50 + ) + pure $ case (webDomain_, webJsonDir_) of + (Just webDomain, Just webJsonDir) -> Just WebPreviewConfig {webDomain, webJsonDir, webCorsFile, webUpdateInterval, webPreviewItemCount} + (Nothing, Nothing) -> Nothing + _ -> errorWithoutStackTrace "--relay-web-domain and --relay-web-dir must both be provided" highlyAvailable <- switch ( long "ha" @@ -283,6 +324,7 @@ coreChatOptsP appDir defaultDbName = do tbqSize, deviceName, chatRelay, + webPreviewConfig, highlyAvailable, yesToUpMigrations, migrationBackupPath, diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 94951d1110..4546985e52 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -83,12 +83,13 @@ import Simplex.Messaging.Version hiding (version) -- 15 - support specifying message scopes for group messages (2025-03-12) -- 16 - support short link data (2025-06-10) -- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10) +-- 18 - relay web capabilities (2026-05-31) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 17 +currentChatVersion = VersionChat 18 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -155,6 +156,10 @@ shortLinkDataVersion = VersionChat 16 memberSupportVoiceVersion :: VersionChat memberSupportVoiceVersion = VersionChat 17 +-- relay sends web preview capabilities to owner +relayWebCapVersion :: VersionChat +relayWebCapVersion = VersionChat 18 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index a0c9dd9ed0..2ea3fa9b84 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -67,6 +67,7 @@ module Simplex.Chat.Store.Groups getGroupMembersByIndexes, getSupportScopeMembersByIndexes, getGroupModerators, + getGroupOwners, getGroupRelayMembers, getGroupMembersForExpiration, getRemovedMembersToCleanup, @@ -98,9 +99,12 @@ module Simplex.Chat.Store.Groups createRelayRequestGroup, updateRelayOwnStatusFromTo, updateRelayOwnStatus_, + getRelaySentWebDomain, + updateRelaySentWebDomain, isRelayGroupRejected, allowRelayGroup, getRelayServedGroups, + getRelayPublishableGroups, getRelayInactiveGroups, createNewContactMemberAsync, createJoiningMember, @@ -1211,6 +1215,15 @@ getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId} (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") (userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) +getGroupOwners :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupOwners db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + ts <- getCurrentTime + map (toContactMember ts cxt user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ?") + (userId, groupId, userContactId, GROwner) + getGroupRelayMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupRelayMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do currentTs <- getCurrentTime @@ -1650,6 +1663,14 @@ updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do let inactiveAt_ = if relayStatus == RSInactive then Just currentTs else Nothing DB.execute db "UPDATE groups SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? WHERE group_id = ?" (relayStatus, inactiveAt_, currentTs, groupId) +getRelaySentWebDomain :: DB.Connection -> GroupInfo -> IO (Maybe Text) +getRelaySentWebDomain db GroupInfo {groupId} = + join <$> maybeFirstRow fromOnly (DB.query db "SELECT relay_sent_web_domain FROM groups WHERE group_id = ?" (Only groupId)) + +updateRelaySentWebDomain :: DB.Connection -> GroupInfo -> Maybe Text -> IO () +updateRelaySentWebDomain db GroupInfo {groupId} webDomain_ = + DB.execute db "UPDATE groups SET relay_sent_web_domain = ? WHERE group_id = ?" (webDomain_, groupId) + -- Flip every RSRejected row sharing the targeted group's relay_request_group_link -- to RSInactive in one statement; returns the refreshed GroupInfo for the targeted groupId. allowRelayGroup :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO GroupInfo @@ -1696,6 +1717,24 @@ getRelayServedGroups db cxt User {userId, userContactId} = do ) (userId, userContactId, RSAccepted, RSActive) +getRelayPublishableGroups :: DB.Connection -> User -> IO [(Int64, B64UrlByteString, Maybe PublicGroupAccess)] +getRelayPublishableGroups db User {userId, userContactId} = + map toRow <$> + DB.query + db + [sql| + SELECT g.group_id, gp.public_group_id, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id AND mu.contact_id = ? + WHERE g.user_id = ? AND g.relay_own_status IN (?, ?) + AND gp.public_group_id IS NOT NULL + |] + (userContactId, userId, RSAccepted, RSActive) + where + toRow ((gId, pgId) :. accessRow) = (gId, pgId, toPublicGroupAccess accessRow) + getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime -> IO [GroupInfo] getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do currentTs <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 3a96756712..cf12db7ec1 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -137,6 +137,7 @@ module Simplex.Chat.Store.Messages getGroupSndStatuses, getGroupSndStatusCounts, getGroupHistoryItems, + getGroupWebPreviewItems, ) where @@ -3716,3 +3717,21 @@ getGroupHistoryItems db user@User {userId} g@GroupInfo {groupId} m count = do LIMIT ? |] (groupMemberId' m, userId, groupId, count) + +getGroupWebPreviewItems :: DB.Connection -> User -> GroupInfo -> Int -> IO [Either StoreError (CChatItem 'CTGroup)] +getGroupWebPreviewItems db user@User {userId} g@GroupInfo {groupId} count = do + ciIds <- + map fromOnly + <$> DB.query + db + [sql| + SELECT i.chat_item_id + FROM chat_items i + WHERE i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 + AND i.item_deleted = 0 + ORDER BY i.item_ts DESC, i.chat_item_id DESC + LIMIT ? + |] + (userId, groupId, count) + reverse <$> mapM (runExceptT . getGroupCIWithReactions db user g) ciIds diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 862a93f00d..20acf0b602 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -36,6 +36,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges import Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders import Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services import Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at +import Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -71,7 +72,8 @@ schemaMigrations = ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges), ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), - ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at) + ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), + ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260601_relay_sent_web_domain.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260601_relay_sent_web_domain.hs new file mode 100644 index 0000000000..1b8efbcead --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260601_relay_sent_web_domain.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260601_relay_sent_web_domain :: Text +m20260601_relay_sent_web_domain = + [r| +ALTER TABLE groups ADD COLUMN relay_sent_web_domain TEXT; +|] + +down_m20260601_relay_sent_web_domain :: Text +down_m20260601_relay_sent_web_domain = + [r| +ALTER TABLE groups DROP COLUMN relay_sent_web_domain; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index f015999274..89cddd48e5 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -978,7 +978,8 @@ CREATE TABLE test_chat_schema.groups ( relay_request_retries bigint DEFAULT 0 NOT NULL, relay_request_delay bigint DEFAULT 0 NOT NULL, relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL, - relay_inactive_at timestamp with time zone + relay_inactive_at timestamp with time zone, + relay_sent_web_domain text ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 84860d35fe..78838c507f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -159,6 +159,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges import Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders import Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services import Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at +import Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -317,7 +318,8 @@ schemaMigrations = ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges), ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), - ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at) + ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), + ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260601_relay_sent_web_domain.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260601_relay_sent_web_domain.hs new file mode 100644 index 0000000000..922a563356 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260601_relay_sent_web_domain.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260601_relay_sent_web_domain :: Query +m20260601_relay_sent_web_domain = + [sql| +ALTER TABLE groups ADD COLUMN relay_sent_web_domain TEXT; +|] + +down_m20260601_relay_sent_web_domain :: Query +down_m20260601_relay_sent_web_domain = + [sql| +ALTER TABLE groups DROP COLUMN relay_sent_web_domain; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index ee857211aa..a986773cb2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -548,15 +548,6 @@ Plan: SEARCH s USING PRIMARY KEY (conn_id=? AND internal_snd_id=?) SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) -Query: - UPDATE rcv_messages - SET receive_attempts = receive_attempts + 1 - WHERE conn_id = ? AND internal_id = ? - RETURNING receive_attempts - -Plan: -SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) - Query: DELETE FROM conn_confirmations WHERE conn_id = ? diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index e31194d151..d750be3275 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1618,6 +1618,18 @@ Plan: SEARCH i USING INDEX idx_chat_items_group_id (group_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT i.chat_item_id + FROM chat_items i + WHERE i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 + AND i.item_deleted = 0 + ORDER BY i.item_ts DESC, i.chat_item_id DESC + LIMIT ? + +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_groups_history (user_id=? AND group_id=? AND include_in_history=? AND item_deleted=?) + Query: SELECT i.chat_item_id, i.contact_id, i.group_id, i.group_scope_tag, i.group_scope_group_member_id, i.note_folder_id FROM chat_items i @@ -5442,6 +5454,36 @@ SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, + g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, + g.business_chat, g.business_member_id, g.customer_member_id, + g.use_relays, g.relay_own_status, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.root_priv_key, g.root_pub_key, g.member_priv_key, + -- GroupMember - membership + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link + + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?) +Plan: +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, @@ -5696,6 +5738,25 @@ Query: FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ? +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) Plan: SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index f7bdcc1eb8..06810d6aab 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -191,7 +191,8 @@ CREATE TABLE groups( relay_request_retries INTEGER NOT NULL DEFAULT 0, relay_request_delay INTEGER NOT NULL DEFAULT 0, relay_request_execute_at TEXT NOT NULL DEFAULT '1970-01-01 00:00:00', - relay_inactive_at TEXT, -- received + relay_inactive_at TEXT, + relay_sent_web_domain TEXT, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 4f40e3a566..f9e36a86ad 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -881,8 +881,13 @@ instance FromJSON ImageData where parseJSON = fmap ImageData . J.parseJSON instance ToJSON ImageData where - toJSON (ImageData t) = J.toJSON t - toEncoding (ImageData t) = J.toEncoding t + toJSON (ImageData t) = J.toJSON $ safeImageData t + toEncoding (ImageData t) = J.toEncoding $ safeImageData t + +safeImageData :: Text -> Text +safeImageData t + | "data:" `T.isPrefixOf` t = t + | otherwise = "" instance ToField ImageData where toField (ImageData t) = toField t diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b98d965254..cd7a5daea9 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1207,8 +1207,8 @@ viewReceivedContactRequest c Profile {fullName, shortDescr} = ] showRelay :: GroupRelay -> StyledString -showRelay GroupRelay {groupRelayId, relayStatus} = - " - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus) +showRelay GroupRelay {groupRelayId, relayStatus, relayCap = RelayCapabilities {webDomain}} = + " - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus) <> maybe "" (\d -> ", web: " <> plain d) webDomain viewGroupRelays :: GroupInfo -> [GroupRelay] -> [StyledString] viewGroupRelays g relays = @@ -1982,10 +1982,10 @@ countactUserPrefText cup = case cup of viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> Maybe MsgSigStatus -> [StyledString] viewGroupUpdated - GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, shortDescr, description, image, groupPreferences = gps, memberAdmission = ma}} - g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', shortDescr = shortDescr', description = description', image = image', groupPreferences = gps', memberAdmission = ma'}} + GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, shortDescr, description, image, groupPreferences = gps, memberAdmission = ma, publicGroup = pg}} + g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', shortDescr = shortDescr', description = description', image = image', groupPreferences = gps', memberAdmission = ma', publicGroup = pg'}} m signed = do - let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated + let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated <> publicGroupAccessUpdated if null update then [] else memberUpdated <> update @@ -2010,6 +2010,18 @@ viewGroupUpdated memberAdmissionUpdated | ma == ma' = [] | otherwise = ["changed member admission rules"] + publicGroupAccessUpdated + | access == access' = [] + | otherwise = ["updated public group access:" <> viewAccess access'] + where + access = pg >>= publicGroupAccess + access' = pg' >>= publicGroupAccess + viewAccess Nothing = " removed" + viewAccess (Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding}) = + maybe "" (\u -> " web=" <> plain u) groupWebPage + <> maybe "" (\d -> " domain=" <> plain d) groupDomain + <> (if domainWebPage then " domain_page=on" else "") + <> (if allowEmbedding then " embed=on" else "") viewGroupProfile :: GroupInfo -> [StyledString] viewGroupProfile g@GroupInfo {groupProfile = GroupProfile {shortDescr, description, image, groupPreferences = gps}} = diff --git a/src/Simplex/Chat/Web.hs b/src/Simplex/Chat/Web.hs new file mode 100644 index 0000000000..2b4fb89137 --- /dev/null +++ b/src/Simplex/Chat/Web.hs @@ -0,0 +1,430 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module Simplex.Chat.Web + ( WebChannelPreview (..), + WebMessage (..), + WebMemberProfile (..), + WebFileInfo (..), + webPreviewWorker, + writeCorsConfig, + removeStaleFiles, + channelContentChanged, + channelProfileUpdated, + channelRemoved, + extractOrigin, + ) +where + +import Control.Concurrent.STM (check, flushTQueue) +import Control.Exception (SomeException, catch) +import Control.Logger.Simple +import Control.Monad (forM_, void, when) +import Control.Monad.Except (runExceptT) +import Data.Either (rights) +import Data.Int (Int64) +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy as LB +import Data.Text.Encoding (encodeUtf8) +import qualified Data.Map.Strict as M +import qualified Data.Set as S +import Data.Maybe (isJust, mapMaybe, maybeToList) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.IO as TIO +import Data.Time.Clock (UTCTime, getCurrentTime) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), mkStoreCxt) +import Simplex.Chat.Markdown (FormattedText (..), MarkdownList, parseMaybeMarkdownList) +import Simplex.Chat.Messages + ( CChatItem (..), + CIDirection (..), + CIFile (..), + CIMeta (..), + CIQDirection (..), + CIQuote (..), + CIReactionCount, + ChatItem (..), + ChatType (..), + ) +import Simplex.Chat.Messages.CIContent (ciMsgContent) +import Simplex.Chat.Protocol (MsgContent, MsgRef (..), QuotedMsg (..), isReport) +import Simplex.Chat.Store.Groups (getGroupOwners, getRelayPublishableGroups) +import Simplex.Chat.Store.Messages (getGroupWebPreviewItems) +import Simplex.Chat.Store.Shared (getGroupInfo) +import Simplex.Chat.Types + ( B64UrlByteString, + GroupInfo (..), + GroupMember (..), + GroupProfile (..), + GroupSummary (..), + ImageData, + LocalProfile (..), + MemberId, + PublicGroupAccess (..), + PublicGroupProfile (..), + User (..), + ) +import Simplex.Messaging.Agent.Store.Common (withTransaction) +import Simplex.Messaging.Encoding.String (strEncode) +import Simplex.Messaging.Util (safeDecodeUtf8) +import qualified URI.ByteString as U +import Simplex.Messaging.Parsers (defaultJSON) +import System.Directory (createDirectoryIfMissing, listDirectory, removeFile, renameFile) +import System.FilePath (dropExtension, takeExtension, ()) +import UnliftIO.STM + +data WebFileInfo = WebFileInfo + { fileName :: String, + fileSize :: Integer + } + deriving (Show) + +data WebMemberProfile = WebMemberProfile + { memberId :: MemberId, + displayName :: Text, + image :: Maybe ImageData + } + deriving (Show) + +data WebMessage = WebMessage + { sender :: Maybe MemberId, + ts :: UTCTime, + content :: MsgContent, + formattedText :: Maybe MarkdownList, + file :: Maybe WebFileInfo, + quote :: Maybe QuotedMsg, + reactions :: [CIReactionCount], + forward :: Maybe Bool, + edited :: Bool + } + deriving (Show) + +data WebChannelPreview = WebChannelPreview + { channel :: GroupProfile, + shortDescription :: Maybe MarkdownList, + welcomeMessage :: Maybe MarkdownList, + members :: [WebMemberProfile], + subscribers :: Maybe Int64, + messages :: [WebMessage], + updatedAt :: UTCTime + } + deriving (Show) + +$(JQ.deriveJSON defaultJSON ''WebFileInfo) + +$(JQ.deriveJSON defaultJSON ''WebMemberProfile) + +$(JQ.deriveJSON defaultJSON ''WebMessage) + +$(JQ.deriveJSON defaultJSON ''WebChannelPreview) + +webPreviewWorker :: WebPreviewConfig -> ChatController -> [User] -> IO () +webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterval} cc users = + forM_ (webPreviewState cc) $ \wps -> do + createDirectoryIfMissing True webJsonDir + initPublishableGroups wps + cleanStaleFiles wps + regenerateCors wps + seedRoutinePending wps + workerLoop wps + where + cxt = mkStoreCxt (config cc) + + workerLoop wps@WebPreviewState {priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal} = do + drainRemovals + drainPriority + handleCors + renderRoutine + noRoutine <- atomically $ S.null <$> readTVar routinePending + when noRoutine waitRefresh + workerLoop wps + where + drainRemovals = atomically (tryReadTQueue filesToRemove) >>= \case + Nothing -> pure () + Just f -> do + removeFile (webJsonDir f) `catch` \(_ :: SomeException) -> pure () + drainRemovals + + -- flush the whole queue and render each group once: a burst of changes in one + -- channel enqueues its id many times, but only needs a single render + drainPriority = do + gIds <- atomically $ flushTQueue priorityRender + forM_ (S.fromList gIds) $ renderOneGroup wps + + handleCors = do + needed <- atomically $ swapTVar corsNeeded False + when needed $ regenerateCors wps + + -- render a single routine item; the main loop calls this once per iteration + renderRoutine = do + mGId <- atomically $ do + pending <- readTVar routinePending + case S.minView pending of + Nothing -> pure Nothing + Just (gId, rest) -> writeTVar routinePending rest >> pure (Just gId) + forM_ mGId $ renderOneGroup wps + + -- routine list drained: wait for the refresh timer or a change signal; only the timer + -- seeds the next full sweep, a change just returns to let the main loop service it + waitRefresh = do + delay <- registerDelay (webUpdateInterval * 1000000) + timerFired <- atomically $ + (True <$ (readTVar delay >>= check)) `orElse` (False <$ takeTMVar wakeSignal) + when timerFired $ seedRoutinePending wps + + initPublishableGroups WebPreviewState {publishableGroupIds} = do + rows <- withTransaction (chatStore cc) $ \db -> + concat <$> mapM (getRelayPublishableGroups db) users + let gIds = M.fromList [(gId, toPublishableGroup pgId access) | (gId, pgId, access) <- rows] + atomically $ writeTVar publishableGroupIds gIds + + cleanStaleFiles WebPreviewState {publishableGroupIds} = do + ids <- readTVarIO publishableGroupIds + let activeFiles = S.fromList $ map pgFileName $ M.elems ids + removeStaleFiles webJsonDir activeFiles + + regenerateCors WebPreviewState {publishableGroupIds} = do + ids <- readTVarIO publishableGroupIds + let entries = mapMaybe pgCorsEntry $ M.elems ids + forM_ webCorsFile $ writeCorsConfig entries + + seedRoutinePending WebPreviewState {publishableGroupIds, routinePending} = + atomically $ M.keysSet <$> readTVar publishableGroupIds >>= writeTVar routinePending + + renderOneGroup WebPreviewState {publishableGroupIds} gId = do + publishable <- atomically $ M.member gId <$> readTVar publishableGroupIds + when publishable $ + renderOrRemoveStale `catch` \(e :: SomeException) -> + logError $ "web preview: error rendering group " <> T.pack (show gId) <> ": " <> T.pack (show e) + where + renderOrRemoveStale = do + r <- withTransaction (chatStore cc) $ \db -> + findUser $ \u -> fmap (\g -> (u, g)) <$> runExceptT (getGroupInfo db cxt u gId) + case r of + Just (u, gInfo) | hasPublicGroup gInfo -> + void $ renderGroupPreview cfg cc u gInfo + _ -> do + fName <- atomically $ do + pg <- M.lookup gId <$> readTVar publishableGroupIds + modifyTVar' publishableGroupIds (M.delete gId) + pure $ pgFileName <$> pg + forM_ fName $ \f -> + removeFile (webJsonDir f) `catch` \(_ :: SomeException) -> pure () + logInfo $ "web preview: group " <> T.pack (show gId) <> " no longer publishable" + + findUser f = go users + where + go [] = pure Nothing + go (u : us) = f u >>= \case + Right a -> pure (Just a) + Left _ -> go us + +renderGroupPreview :: WebPreviewConfig -> ChatController -> User -> GroupInfo -> IO (Maybe (Text, CorsOrigin)) +renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gInfo@GroupInfo {groupProfile = gp@GroupProfile {shortDescr = sd, description = wd, publicGroup}, groupSummary = GroupSummary {publicMemberCount}} = + case publicGroup of + Just PublicGroupProfile {publicGroupId, publicGroupAccess} -> do + let fName = publicGroupIdFileName publicGroupId <> ".json" + (items, owners) <- withTransaction (chatStore cc) $ \db -> do + is <- getGroupWebPreviewItems db user gInfo webPreviewItemCount + os <- getGroupOwners db cxt user gInfo + pure (is, os) + ts <- getCurrentTime + let rendered = mapMaybe toRenderedItem $ rights items + msgs = map fst rendered + senders = collectSenders $ map memberToProfile owners <> concatMap snd rendered + preview = WebChannelPreview + { channel = gp, + shortDescription = toFormattedText =<< sd, + welcomeMessage = toFormattedText =<< wd, + members = senders, + subscribers = publicMemberCount, + messages = msgs, + updatedAt = ts + } + let destPath = webJsonDir fName + tmpPath = destPath <> ".tmp" + LB.writeFile tmpPath (J.encode preview) + renameFile tmpPath destPath + pure $ corsEntry publicGroupId <$> publicGroupAccess + Nothing -> pure Nothing + where + cxt = mkStoreCxt (config cc) + +channelContentChanged :: ChatController -> Int64 -> STM () +channelContentChanged cc gId = + forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, priorityRender, routinePending, wakeSignal} -> do + ids <- readTVar publishableGroupIds + when (M.member gId ids) $ do + writeTQueue priorityRender gId + modifyTVar' routinePending (S.delete gId) + void $ tryPutTMVar wakeSignal () + +channelProfileUpdated :: ChatController -> Int64 -> GroupProfile -> STM () +channelProfileUpdated cc gId GroupProfile {publicGroup} = + forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal} -> + case publicGroup of + Just PublicGroupProfile {publicGroupId, publicGroupAccess} -> do + let pg = PublishableGroup + { pgFileName = publicGroupIdFileName publicGroupId <> ".json", + pgCorsEntry = corsEntry publicGroupId <$> publicGroupAccess + } + modifyTVar' publishableGroupIds (M.insert gId pg) + writeTQueue priorityRender gId + modifyTVar' routinePending (S.delete gId) + writeTVar corsNeeded True + void $ tryPutTMVar wakeSignal () + Nothing -> do + ids <- readTVar publishableGroupIds + forM_ (pgFileName <$> M.lookup gId ids) $ writeTQueue filesToRemove + modifyTVar' publishableGroupIds (M.delete gId) + modifyTVar' routinePending (S.delete gId) + writeTVar corsNeeded True + void $ tryPutTMVar wakeSignal () + +channelRemoved :: ChatController -> Int64 -> STM () +channelRemoved cc gId = + forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, filesToRemove, corsNeeded, routinePending, wakeSignal} -> do + ids <- readTVar publishableGroupIds + forM_ (pgFileName <$> M.lookup gId ids) $ writeTQueue filesToRemove + modifyTVar' publishableGroupIds (M.delete gId) + modifyTVar' routinePending (S.delete gId) + writeTVar corsNeeded True + void $ tryPutTMVar wakeSignal () + +toRenderedItem :: CChatItem 'CTGroup -> Maybe (WebMessage, [WebMemberProfile]) +toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, itemForwarded, itemEdited}, content, formattedText, quotedItem, reactions, file}) + | isJust itemTimed = Nothing + | otherwise = case ciMsgContent content of + Just mc | not (isReport mc) -> + let (sender, senderProfile) = case chatDir of + CIGroupRcv m@GroupMember {memberId} -> (Just memberId, [memberToProfile m]) + _ -> (Nothing, []) + quotedProfile = case quotedItem of + Just CIQuote {chatDir = CIQGroupRcv (Just m)} -> [memberToProfile m] + _ -> [] + in Just + ( WebMessage + { sender, + ts = itemTs, + content = mc, + formattedText, + file = webFileInfo <$> file, + quote = quotedItem >>= ciQuoteToQuotedMsg, + reactions, + forward = if isJust itemForwarded then Just True else Nothing, + edited = itemEdited + }, + senderProfile <> quotedProfile + ) + _ -> Nothing + +ciQuoteToQuotedMsg :: CIQuote c -> Maybe QuotedMsg +ciQuoteToQuotedMsg CIQuote {chatDir = qDir, sharedMsgId, sentAt, content = qContent} = + Just QuotedMsg + { msgRef = MsgRef + { msgId = sharedMsgId, + sentAt, + sent = case qDir of + CIQDirectSnd -> True + CIQGroupSnd -> True + _ -> False, + memberId = case qDir of + CIQGroupRcv (Just GroupMember {memberId}) -> Just memberId + _ -> Nothing + }, + content = qContent + } + +webFileInfo :: CIFile d -> WebFileInfo +webFileInfo CIFile {fileName, fileSize} = WebFileInfo {fileName, fileSize} + +collectSenders :: [WebMemberProfile] -> [WebMemberProfile] +collectSenders = M.elems . M.fromList . map (\p@WebMemberProfile {memberId} -> (memberId, p)) + +memberToProfile :: GroupMember -> WebMemberProfile +memberToProfile GroupMember {memberId, memberProfile = LocalProfile {displayName, image}} = + WebMemberProfile {memberId, displayName, image} + +toPublishableGroup :: B64UrlByteString -> Maybe PublicGroupAccess -> PublishableGroup +toPublishableGroup pgId access = + PublishableGroup + { pgFileName = publicGroupIdFileName pgId <> ".json", + pgCorsEntry = corsEntry pgId <$> access + } + +corsEntry :: B64UrlByteString -> PublicGroupAccess -> (Text, CorsOrigin) +corsEntry publicGroupId PublicGroupAccess {groupWebPage, allowEmbedding} = + let fName = T.pack $ publicGroupIdFileName publicGroupId <> ".json" + origin + | allowEmbedding = CorsAny + | otherwise = CorsOrigins $ mapMaybe extractOrigin $ maybeToList groupWebPage + in (fName, origin) + +extractOrigin :: Text -> Maybe Text +extractOrigin url = + case U.parseURI U.laxURIParserOptions (encodeUtf8 url) of + Right uri@U.URI {uriScheme = U.Scheme sch, uriAuthority = Just _} + | sch == "https" || sch == "http" -> + let originUri = uri {U.uriPath = "", U.uriQuery = U.Query [], U.uriFragment = Nothing} + origin = safeDecodeUtf8 $ U.serializeURIRef' originUri + in if T.all safeOriginChar origin then Just origin else Nothing + _ -> Nothing + where + -- percent-encoded bytes in the host (e.g. %22, %0a) are decoded by serializeURIRef', + -- so reject any origin with characters that could break out of the Caddy CORS config or header + safeOriginChar c = + (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c `elem` (".-:/[]" :: [Char]) + +channelPath :: Text +channelPath = "/channel/" + +writeCorsConfig :: [(Text, CorsOrigin)] -> FilePath -> IO () +writeCorsConfig entries path = + TIO.writeFile path $ T.unlines $ + ["map {path} {cors_origin} {"] + <> map corsLine entries + <> [ " default \"\"", + "}", + "header " <> channelPath <> "*.json Access-Control-Allow-Origin {cors_origin}", + "header " <> channelPath <> "*.json Access-Control-Allow-Methods \"GET, OPTIONS\"" + ] + where + corsLine (fName, origin) = case origin of + CorsAny -> " " <> channelPath <> fName <> " \"*\"" + CorsOrigins origins -> case origins of + [] -> " # " <> fName <> " (no origin configured)" + (o : _) -> " " <> channelPath <> fName <> " \"" <> o <> "\"" + +removeStaleFiles :: FilePath -> S.Set FilePath -> IO () +removeStaleFiles dir activeFiles = do + let -- matches ".json" and leftover ".json.tmp" from an interrupted write + isPreviewFile f = + let f' = if takeExtension f == ".tmp" then dropExtension f else f + base = dropExtension f' + in takeExtension f' == ".json" && not (null base) && all isBase64Url base + isBase64Url c = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' + allFiles <- S.filter isPreviewFile . S.fromList <$> listDirectory dir + mapM_ (\f -> removeFile (dir f)) $ S.difference allFiles activeFiles + +toFormattedText :: Text -> Maybe MarkdownList +toFormattedText t = case parseMaybeMarkdownList t of + Just fts | any hasFormat fts -> Just fts + _ -> Nothing + where + hasFormat (FormattedText fmt _) = isJust fmt + +publicGroupIdFileName :: B64UrlByteString -> String +publicGroupIdFileName = B.unpack . strEncode + +hasPublicGroup :: GroupInfo -> Bool +hasPublicGroup GroupInfo {groupProfile = GroupProfile {publicGroup}} = isJust publicGroup + diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index f92411839f..cd6d549581 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -27,8 +27,10 @@ import Simplex.Chat.Controller (ChatConfig (..)) import qualified Simplex.Chat.Markdown as MD import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB +import Simplex.Chat.Protocol (memberSupportVoiceVersion) import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) +import Simplex.Messaging.Version import System.FilePath (()) import Test.Hspec hiding (it) @@ -1492,7 +1494,7 @@ testVoiceCaptchaOldClient ps@TestParams {tmpPath} = do setPermissions mockScript $ setOwnerExecutable True $ setOwnerReadable True $ setOwnerWritable True emptyPermissions withDirectoryServiceVoiceCaptcha ps mockScript $ \superUser dsLink -> withNewTestChat ps "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgVPrev "cath" cathProfile $ \cath -> do + withNewTestChatCfg ps testCfg {chatVRange = (chatVRange testCfg) {maxVersion = prevVersion memberSupportVoiceVersion}} "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" bob #> "@'SimpleX Directory' /role 1" diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index ede3c1f2a2..442834b244 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -24,11 +24,12 @@ import Control.Monad.Reader import Data.Functor (($>)) import Data.List (dropWhileEnd, find) import Data.Maybe (isNothing) +import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (getCurrentTime) import Network.Socket import Simplex.Chat -import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), defaultSimpleNetCfg) +import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), WebPreviewConfig (..), defaultSimpleNetCfg) import Simplex.Chat.Core import Simplex.Chat.Library.Commands import Simplex.Chat.Options @@ -153,6 +154,7 @@ testCoreOpts = tbqSize = 16, deviceName = Nothing, chatRelay = False, + webPreviewConfig = Nothing, highlyAvailable = False, yesToUpMigrations = False, migrationBackupPath = Nothing, @@ -162,6 +164,9 @@ testCoreOpts = relayTestOpts :: ChatOpts relayTestOpts = testOpts {coreOptions = testCoreOpts {chatRelay = True}} +relayWebTestOpts :: Text -> FilePath -> Maybe FilePath -> ChatOpts +relayWebTestOpts webDomain webDir webCorsFile = testOpts {coreOptions = testCoreOpts {chatRelay = True, webPreviewConfig = Just WebPreviewConfig {webDomain, webJsonDir = webDir, webCorsFile, webUpdateInterval = 300, webPreviewItemCount = 50}}} + #if !defined(dbPostgres) getTestOpts :: Bool -> ScrubbedBytes -> ChatOpts getTestOpts maintenance dbKey = testOpts {coreOptions = testCoreOpts {maintenance, dbOptions = (dbOptions testCoreOpts) {dbKey}}} diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 4b09347dcf..57095fb28f 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -1,5 +1,7 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} module ChatTests.ChatRelays where @@ -16,8 +18,13 @@ import qualified Data.Text as T import ProtocolTests (testGroupProfile) import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink (..), MsgContent (..)) import Simplex.Chat.Types (GroupProfile (..)) +import Simplex.Chat.Controller (CorsOrigin (..)) +import Simplex.Chat.Web (WebChannelPreview (..), WebMessage (..), extractOrigin, removeStaleFiles, writeCorsConfig) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Util (decodeJSON) +import qualified Data.Set as S +import System.Directory (createDirectoryIfMissing, doesFileExist, listDirectory) +import System.FilePath (takeExtension, ()) import Test.Hspec hiding (it) chatRelayTests :: SpecWith TestParams @@ -28,6 +35,19 @@ chatRelayTests = do it "re-add soft-deleted relay by same name" testReAddRelaySameName it "test chat relay" testChatRelayTest it "relay profile updated in address" testRelayProfileUpdateInAddress + describe "relay capabilities" $ do + it "relay sends webDomain in capabilities" testRelayWebCapabilities + describe "web preview" $ do + it "render messages and members" testWebPreviewRender + it "incremental render adds new messages" testWebPreviewIncremental + it "edited and deleted messages" testWebPreviewEditedDeleted + it "reactions in rendered messages" testWebPreviewReactions + it "non-public group produces no file" testWebPreviewNonPublic + it "multiple channels produce multiple files" testWebPreviewMultipleChannels + it "channel deletion removes preview file" testWebPreviewChannelDeleted + it "removeStaleFiles preserves non-base64url files" testWebPreviewStaleCleanup + it "generate CORS config" testWebPreviewCors + it "extractOrigin strips path from URL" testExtractOrigin describe "share channel card" $ do it "share channel card in direct chat" testShareChannelDirect it "share channel card in group" testShareChannelGroup @@ -325,6 +345,238 @@ testShareChannelChannel ps = getTermLine2 :: TestCC -> IO (String, String) getTermLine2 c = (,) <$> getTermLine c <*> getTermLine c +testRelayWebCapabilities :: HasCallStack => TestParams -> IO () +testRelayWebCapabilities ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" (tmpPath ps "web_cap") Nothing) "bob" bobProfile $ \relay -> do + rName <- userName relay + relay ##> "/ad" + (relaySLink, _cLink) <- getContactLinks relay True + alice ##> ("/relays name=" <> rName <> " " <> relaySLink) + alice <## "ok" + alice ##> "/public group relays=1 #news" + alice <## "group #news is created" + alice <## "wait for selected relay(s) to join, then you can invite members via group link" + concurrentlyN_ + [ do + alice <## "#news: group link relays updated, current relays:" + alice <### [EndsWith ": active, web: relay.example.com"] + alice <## "group link:" + _ <- getTermLine alice + pure (), + relay <## "#news: you joined the group as relay" + ] + +-- Helper: set up relay with web config + channel +withWebChannel :: TestParams -> String -> (TestCC -> TestCC -> FilePath -> IO ()) -> IO () +withWebChannel ps gName test = do + let webDir = tmpPath ps "web_" <> gName + corsFile = tmpPath ps "cors_" <> gName <> ".conf" + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" webDir (Just corsFile)) "bob" bobProfile $ \relay -> do + _ <- setupRelay alice relay + createChannelWithRelayWeb gName alice relay + test alice relay webDir + +createChannelWithRelayWeb :: HasCallStack => String -> TestCC -> TestCC -> IO () +createChannelWithRelayWeb gName owner relay = do + owner ##> ("/public group relays=1 #" <> gName) + owner <## ("group #" <> gName <> " is created") + owner <## "wait for selected relay(s) to join, then you can invite members via group link" + concurrentlyN_ + [ do + owner <## ("#" <> gName <> ": group link relays updated, current relays:") + owner <### [EndsWith ": active, web: relay.example.com"] + owner <## "group link:" + _ <- getTermLine owner + pure (), + relay <## ("#" <> gName <> ": you joined the group as relay") + ] + +-- Poll for a JSON preview file written by the worker that satisfies predicate, with timeout +waitPreviewWith :: HasCallStack => FilePath -> (WebChannelPreview -> Bool) -> IO WebChannelPreview +waitPreviewWith webDir check = go 50 + where + go :: Int -> IO WebChannelPreview + go 0 = error "waitPreview: timed out waiting for matching JSON file" + go n = do + files <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + case files of + [f] -> do + jsonBytes <- LB.readFile (webDir f) + case J.eitherDecode jsonBytes of + Right p | check p -> pure p + _ -> threadDelay 100000 >> go (n - 1) + _ -> threadDelay 100000 >> go (n - 1) + +waitPreview :: HasCallStack => FilePath -> IO WebChannelPreview +waitPreview webDir = waitPreviewWith webDir (const True) + +testWebPreviewRender :: HasCallStack => TestParams -> IO () +testWebPreviewRender ps = + withWebChannel ps "news" $ \alice relay webDir -> do + alice #> "#news hello from the channel" + relay <# "#news> hello from the channel" + alice #> "#news second message" + relay <# "#news> second message" + wPreview <- waitPreviewWith webDir (\p -> length (messages p) >= 2) + let GroupProfile {displayName = chName} = channel wPreview + chName `shouldBe` "news" + length (messages wPreview) `shouldBe` 2 + content (messages wPreview !! 0) `shouldBe` MCText "hello from the channel" + content (messages wPreview !! 1) `shouldBe` MCText "second message" + length (members wPreview) `shouldSatisfy` (>= 1) + all (\m -> ts m > read "2020-01-01 00:00:00 UTC") (messages wPreview) `shouldBe` True + jsonFiles <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length jsonFiles `shouldBe` 1 + +testWebPreviewIncremental :: HasCallStack => TestParams -> IO () +testWebPreviewIncremental ps = + withWebChannel ps "inc" $ \alice relay webDir -> do + alice #> "#inc first" + relay <# "#inc> first" + p1 <- waitPreviewWith webDir (\p -> length (messages p) >= 1) + length (messages p1) `shouldBe` 1 + content (messages p1 !! 0) `shouldBe` MCText "first" + alice #> "#inc second" + relay <# "#inc> second" + alice #> "#inc third" + relay <# "#inc> third" + p2 <- waitPreviewWith webDir (\p -> length (messages p) >= 3) + length (messages p2) `shouldBe` 3 + content (messages p2 !! 0) `shouldBe` MCText "first" + content (messages p2 !! 1) `shouldBe` MCText "second" + content (messages p2 !! 2) `shouldBe` MCText "third" + +testWebPreviewEditedDeleted :: HasCallStack => TestParams -> IO () +testWebPreviewEditedDeleted ps = + withWebChannel ps "ed" $ \alice relay webDir -> do + alice #> "#ed msg one" + relay <# "#ed> msg one" + alice #> "#ed msg two" + relay <# "#ed> msg two" + msgId2 <- lastItemId alice + alice #> "#ed msg three" + relay <# "#ed> msg three" + msgId3 <- lastItemId alice + alice ##> ("/_update item #1 " <> msgId2 <> " text msg two edited") + alice <# "#ed [edited] msg two edited" + relay <# "#ed> [edited] msg two edited" + alice #$> ("/_delete item #1 " <> msgId3 <> " broadcast", id, "message marked deleted") + relay <# "#ed> [marked deleted] msg three" + p <- waitPreviewWith webDir (\p -> length (messages p) == 2 && any edited (messages p)) + length (messages p) `shouldBe` 2 + content (messages p !! 0) `shouldBe` MCText "msg one" + content (messages p !! 1) `shouldBe` MCText "msg two edited" + edited (messages p !! 0) `shouldBe` False + edited (messages p !! 1) `shouldBe` True + +testWebPreviewReactions :: HasCallStack => TestParams -> IO () +testWebPreviewReactions ps = + withWebChannel ps "react" $ \alice relay webDir -> do + alice #> "#react hello" + relay <# "#react> hello" + alice ##> "+1 #react hello" + alice <## "added 👍" + relay <# "#react alice> > hello" + relay <## " + 👍" + p <- waitPreviewWith webDir (\p -> not (null (messages p)) && not (null (reactions (head (messages p))))) + length (messages p) `shouldBe` 1 + length (reactions (messages p !! 0)) `shouldSatisfy` (>= 1) + +testWebPreviewNonPublic :: HasCallStack => TestParams -> IO () +testWebPreviewNonPublic ps = do + let webDir = tmpPath ps "web_nonpub" + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" webDir Nothing) "bob" bobProfile $ \relay -> do + _ <- setupRelay alice relay + alice ##> "/g private" + alice <## "group #private is created" + alice <## "to add members use /a private or /create link #private" + alice #> "#private hello" + threadDelay 2000000 + files <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length files `shouldBe` 0 + +testWebPreviewMultipleChannels :: HasCallStack => TestParams -> IO () +testWebPreviewMultipleChannels ps = do + let webDir = tmpPath ps "web_multi" + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" webDir Nothing) "bob" bobProfile $ \relay -> do + _ <- setupRelay alice relay + createChannelWithRelayWeb "ch1" alice relay + createChannelWithRelayWeb "ch2" alice relay + alice #> "#ch1 msg in ch1" + relay <# "#ch1> msg in ch1" + alice #> "#ch2 msg in ch2" + relay <# "#ch2> msg in ch2" + threadDelay 2000000 + files <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length files `shouldBe` 2 + +testWebPreviewChannelDeleted :: HasCallStack => TestParams -> IO () +testWebPreviewChannelDeleted ps = + withWebChannel ps "del" $ \alice relay webDir -> do + alice #> "#del hello" + relay <# "#del> hello" + _ <- waitPreviewWith webDir (\p -> not (null (messages p))) + jsonFiles <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length jsonFiles `shouldBe` 1 + let previewFile = webDir head jsonFiles + alice ##> "/d #del" + alice <## "#del: you deleted the group (signed)" + relay <## "#del: alice deleted the group (signed)" + relay <## "use /d #del to delete the local copy of the group" + waitFileDeleted previewFile 50 + +testWebPreviewStaleCleanup :: HasCallStack => TestParams -> IO () +testWebPreviewStaleCleanup ps = do + let webDir = tmpPath ps "web_stale_unit" + activeFile = "abc123.json" + staleFile = "AAAA_stale.json" + safeFile = "my.config.json" + createDirectoryIfMissing True webDir + writeFile (webDir activeFile) "{}" + writeFile (webDir staleFile) "{}" + writeFile (webDir safeFile) "{}" + removeStaleFiles webDir (S.singleton activeFile) + doesFileExist (webDir staleFile) `shouldReturn` False + doesFileExist (webDir safeFile) `shouldReturn` True + doesFileExist (webDir activeFile) `shouldReturn` True + +waitFileDeleted :: HasCallStack => FilePath -> Int -> IO () +waitFileDeleted _ 0 = error "waitFileDeleted: timed out" +waitFileDeleted path n = + doesFileExist path >>= \case + False -> pure () + True -> threadDelay 100000 >> waitFileDeleted path (n - 1) + +testWebPreviewCors :: HasCallStack => TestParams -> IO () +testWebPreviewCors ps = do + let corsFile = tmpPath ps "simplex-cors.conf" + entries = + [ ("abc123.json", CorsAny), + ("def456.json", CorsOrigins ["https://owner-site.com"]), + ("ghi789.json", CorsOrigins []) + ] + writeCorsConfig entries corsFile + corsContent <- readFile corsFile + corsContent `shouldContain` "/channel/abc123.json \"*\"" + corsContent `shouldContain` "/channel/def456.json \"https://owner-site.com\"" + corsContent `shouldContain` "# ghi789.json (no origin configured)" + corsContent `shouldContain` "Access-Control-Allow-Origin" + corsContent `shouldContain` "Access-Control-Allow-Methods" + +testExtractOrigin :: HasCallStack => TestParams -> IO () +testExtractOrigin _ps = do + extractOrigin "https://owner.example.com/channel.html" `shouldBe` Just "https://owner.example.com" + extractOrigin "https://owner.example.com/path/to/page?q=1#frag" `shouldBe` Just "https://owner.example.com" + extractOrigin "https://owner.example.com:8443/page" `shouldBe` Just "https://owner.example.com:8443" + extractOrigin "https://owner.example.com" `shouldBe` Just "https://owner.example.com" + extractOrigin "http://localhost:3000/preview" `shouldBe` Just "http://localhost:3000" + extractOrigin "ftp://example.com/file" `shouldBe` Nothing + extractOrigin "not-a-url" `shouldBe` Nothing + -- Create a public group with relay=1, wait for relay to join createChannelWithRelay :: HasCallStack => String -> TestCC -> TestCC -> IO () createChannelWithRelay gName owner relay = do diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 10f8808015..1482a9de10 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -133,7 +133,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-17\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-18\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -244,13 +244,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-18\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-18\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -265,7 +265,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-18\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" diff --git a/website/.eleventy.js b/website/.eleventy.js index f0310c5665..0567dd45d8 100644 --- a/website/.eleventy.js +++ b/website/.eleventy.js @@ -310,7 +310,7 @@ module.exports = function (ty) { ty.addPassthroughCopy("src/img") ty.addPassthroughCopy("src/video") ty.addPassthroughCopy("src/css") - ty.addPassthroughCopy("src/js") + ty.addPassthroughCopy("src/js/**/*.js") ty.addPassthroughCopy("src/lottie_file") ty.addPassthroughCopy("src/contact/*.js") ty.addPassthroughCopy("src/call") diff --git a/website/channel_sample.html b/website/channel_sample.html new file mode 100644 index 0000000000..169db55599 --- /dev/null +++ b/website/channel_sample.html @@ -0,0 +1,28 @@ + + + + + + SimpleX Channel Preview + + + + +
+ + + diff --git a/website/src/js/channel-preview.jsc b/website/src/js/channel-preview.jsc new file mode 100644 index 0000000000..be572fd8de --- /dev/null +++ b/website/src/js/channel-preview.jsc @@ -0,0 +1,1548 @@ +#include "simplex-lib.jsc" + +(function() { + +#include "qrcode.js" + +const STYLE = ` +.simplex-preview-container { + --sp-bg: var(--sp-light-bg, #fff); + --sp-text: #000; + --sp-text-secondary: #8b8786; + --sp-text-muted: #333; + --sp-text-small: #888; + --sp-bubble: #f5f5f6; + --sp-quote: #ececee; + --sp-border: #e5e5e5; + --sp-link: #0088ff; + --sp-link-hover: #0077e0; + --sp-btn: #007AE5; + --sp-btn-hover: #006BC9; + --sp-color-blue: #0053d0; + --sp-color-black: #000; + --sp-color-white: #000; + --sp-qr-fg: #062D56; + --sp-qr-bg: #ffffff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 15px; + line-height: 1.4; + color: var(--sp-text); + background: var(--sp-bg); + width: 100%; + height: 100%; + padding: 0; + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + display: flex; + justify-content: center; +} + +.simplex-preview-container.simplex-scheme-dark, +.dark .simplex-preview-container.simplex-scheme-site { + --sp-bg: var(--sp-dark-bg, #000832); + --sp-text: #FFFBFA; + --sp-text-secondary: #B3AFAE; + --sp-text-muted: #B3AFAE; + --sp-text-small: #aaa; + --sp-bubble: #071C46; + --sp-quote: #1B325C; + --sp-border: #3A3A3C; + --sp-link: #70F0F9; + --sp-link-hover: #66D9E2; + --sp-btn: #7EF1F9; + --sp-btn-hover: #75DCE4; + --sp-btn-text: #000; + --sp-color-blue: #70F0F9; + --sp-color-black: #fff; + --sp-color-white: #fff; + --sp-qr-fg: #FFFBFA; + --sp-qr-bg: transparent; +} + +.simplex-preview-header { + position: sticky; + top: 0; + z-index: 10; + background: var(--sp-bg); + border-bottom: 1px solid var(--sp-border); + padding: 8px 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.simplex-preview-header-avatar { + width: 36px; + height: 36px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; +} + +.simplex-preview-header-info { + flex: 1; + min-width: 0; +} + +.simplex-preview-header-name { + font-size: 17px; + font-weight: 600; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.simplex-preview-header-description { + font-size: 13px; + color: var(--sp-text-secondary); + margin: 2px 0 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.simplex-preview-join-btn { + flex-shrink: 0; + background: var(--sp-btn); + color: var(--sp-btn-text, #fff); + border: none; + border-radius: 34px; + padding: 6px 10px 6px 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 5px; + font-family: inherit; +} + +.simplex-preview-join-btn svg { + width: 15.4px; + height: 15.4px; + flex-shrink: 0; + margin-left: 2px; +} + +.simplex-preview-container .simplex-logo-light-bg { + display: none; +} + +.simplex-preview-container.simplex-scheme-dark .simplex-logo-dark-bg, +.dark .simplex-preview-container.simplex-scheme-site .simplex-logo-dark-bg { + display: none; +} + +.simplex-preview-container.simplex-scheme-dark .simplex-logo-light-bg, +.dark .simplex-preview-container.simplex-scheme-site .simplex-logo-light-bg { + display: inline; +} + +.simplex-preview-join-btn:hover { + background: var(--sp-btn-hover); +} + +.simplex-preview-messages { + padding: 8px 16px 32px; +} + +.simplex-preview-date-separator { + text-align: center; + padding: 8px 0; + font-size: 12px; + color: var(--sp-text-secondary); + font-weight: 500; +} + +.simplex-preview-msg-group { + padding: 0 8px; +} + +.simplex-preview-msg-name { + font-size: 13.5px; + color: var(--sp-text-secondary); + padding: 0 0 2px 0; + margin-left: 39px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.simplex-preview-msg-name-role { + font-weight: 500; + margin-left: 8px; +} + +.simplex-preview-msg-row { + display: flex; + align-items: flex-start; + margin-bottom: 2px; +} + +.simplex-preview-msg-row.has-gap { + margin-bottom: 6px; +} + +.simplex-preview-msg-avatar { + width: 30px; + height: 30px; + border-radius: 7px; + object-fit: cover; + flex-shrink: 0; + margin-right: 9px; +} + +.simplex-preview-msg-avatar-placeholder { + width: 30px; + flex-shrink: 0; + margin-right: 9px; +} + +.simplex-preview-bubble { + position: relative; + background: var(--sp-bubble); + border-radius: 18px; + min-width: 80px; + overflow: visible; +} + +.simplex-preview-bubble-inner { + border-radius: 18px; + overflow: hidden; +} + +.simplex-preview-bubble.has-tail { + border-bottom-left-radius: 0; +} + +.simplex-preview-bubble.has-tail .simplex-preview-bubble-inner { + border-bottom-left-radius: 0; +} + +.simplex-preview-bubble-tail { + position: absolute; + bottom: 0; + left: -9px; + width: 9px; + height: 16px; + color: var(--sp-bubble); +} + +.simplex-preview-bubble.media-only { + background: transparent; +} + +.simplex-preview-meta-overlay { + position: absolute; + bottom: 6px; + right: 12px; + font-size: 12px; + color: #fff; + text-shadow: 0 0 4px rgba(0,0,0,0.7), 0 0 2px rgba(0,0,0,0.9); + white-space: nowrap; +} + +.simplex-preview-meta-overlay .simplex-preview-meta-edited { + font-style: italic; +} + +.simplex-preview-forwarded-header { + background: var(--sp-quote); + padding: 6px 12px 6px 8px; + font-size: 12px; + font-style: italic; + color: var(--sp-text-secondary); + display: flex; + align-items: center; + gap: 4px; +} + +.simplex-preview-quote { + background: var(--sp-quote); + display: flex; + width: 100%; +} + +.simplex-preview-quote-content { + flex: 1; + padding: 6px 12px; + min-width: 0; +} + +.simplex-preview-quote-sender { + font-size: 13.5px; + color: var(--sp-text-secondary); + margin-bottom: 2px; +} + +.simplex-preview-quote-text { + font-size: 15px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.simplex-preview-quote-thumb { + width: 68px; + height: 68px; + object-fit: cover; + flex-shrink: 0; +} + +.simplex-preview-quote-file-icon { + padding: 6px 4px 0 0; + flex-shrink: 0; + color: var(--sp-text-secondary); +} + +.simplex-preview-text { + padding: 7px 12px; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.simplex-preview-text a { + color: var(--sp-link); + text-decoration: none; +} + +.simplex-preview-text a:hover { + text-decoration: underline; +} + +.simplex-preview-image { + display: block; + max-width: 100%; +} + +.simplex-preview-image.landscape { + width: 400px; +} + +.simplex-preview-image.portrait { + width: 300px; +} + +.simplex-preview-image-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 120px; + height: 80px; + background: var(--sp-quote); + border-radius: 12px; + color: var(--sp-text-secondary); +} + +.simplex-preview-image-placeholder svg { + width: 32px; + height: 32px; +} + +.simplex-preview-link-card { + display: block; + max-width: 400px; +} + +.simplex-preview-link-card-image { + display: block; + width: 100%; +} + +.simplex-preview-link-card-body { + padding: 6px 12px; +} + +.simplex-preview-link-card-title { + font-size: 15px; + line-height: 22px; + margin-bottom: 4px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.simplex-preview-link-card-description { + font-size: 14px; + line-height: 20px; + color: var(--sp-text-muted); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 12; + -webkit-box-orient: vertical; +} + +.simplex-preview-link-card-uri { + font-size: 12px; + color: var(--sp-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.simplex-preview-file-indicator { + padding: 7px 12px; + display: flex; + align-items: center; + gap: 8px; + color: var(--sp-text-secondary); +} + +.simplex-preview-file-icon { + width: 22px; + height: 22px; + flex-shrink: 0; +} + +.simplex-preview-file-name { + font-size: 14px; + color: var(--sp-text); +} + +.simplex-preview-file-size { + font-size: 12px; + color: var(--sp-text-secondary); +} + +.simplex-preview-voice { + padding: 7px 12px; + display: flex; + align-items: center; + gap: 8px; + color: var(--sp-text-secondary); + font-size: 14px; +} + +.simplex-preview-meta { + float: right; + font-size: 12px; + color: var(--sp-text-secondary); + padding: 0 2px 0 12px; + margin-top: 4px; + white-space: nowrap; +} + +.simplex-preview-meta-edited { + font-style: italic; +} + +.simplex-preview-reactions { + display: flex; + flex-wrap: wrap; + padding: 2px 5px 2px; +} + +.simplex-preview-reaction { + font-size: 12px; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; + border-radius: 8px; + padding: 2px 5px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.simplex-preview-reaction-count { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: var(--sp-text-secondary); + font-size: 11.5px; +} + +.simplex-preview-empty { + text-align: center; + padding: 48px 16px; + color: var(--sp-text-secondary); +} + +.simplex-preview-text .secret { + background: var(--sp-text-secondary); + color: transparent; + border-radius: 4px; + cursor: pointer; + user-select: none; + transition: all 0.2s; +} + +.simplex-preview-text .secret.visible { + background: transparent; + color: inherit; +} + +.simplex-preview-text .small-text { + font-size: 13px; + color: var(--sp-text-small); +} + +.simplex-preview-text .red { color: #DD0000; } +.simplex-preview-text .green { color: #20BD3D; } +.simplex-preview-text .blue { color: var(--sp-color-blue); } +.simplex-preview-text .yellow { color: #DEBD00; } +.simplex-preview-text .cyan { color: #0AC4D1; } +.simplex-preview-text .magenta { color: magenta; } +.simplex-preview-text .black { color: var(--sp-color-black); } +.simplex-preview-text .white { color: var(--sp-color-white); } + +.simplex-preview-main { + flex: 1; + min-width: 0; + max-width: 640px; + overflow-y: auto; + overscroll-behavior: contain; + position: relative; +} + +.simplex-preview-info { + overflow-y: auto; + overscroll-behavior: contain; + background: var(--sp-bg); +} + +.simplex-preview-info-close { + display: none; +} + +.simplex-preview-info-avatar { + width: 192px; + height: 192px; + border-radius: 42px; + object-fit: cover; + display: block; + margin: 12px auto; +} + +.simplex-preview-info-name { + font-size: 34px; + font-weight: 700; + text-align: center; + margin: 0; +} + +.simplex-preview-info-descr { + font-size: 14px; + color: var(--sp-text-secondary); + text-align: center; + margin: 8px 0; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.simplex-preview-info-descr a { + color: var(--sp-link); + text-decoration: none; +} + +.simplex-preview-info-descr a:hover { + text-decoration: underline; +} + +.simplex-preview-info-subscribers { + font-size: 14px; + color: var(--sp-text-secondary); + text-align: center; + margin: 0 0 16px; +} + +.simplex-preview-info .simplex-preview-join-btn { + display: block; + text-align: center; + margin-top: 20px; + width: 100%; +} + +.simplex-preview-conversion { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.simplex-preview-divider { + width: 100%; + height: 1px; + background: var(--sp-border); + margin: 40px 0; +} + +.simplex-preview-conversion-title { + font-size: 18px; + font-weight: 600; + text-align: center; + margin: 0 0 16px; +} + +.simplex-preview-qr-toggle { + font-size: 14px; + color: var(--sp-link); + cursor: pointer; + text-decoration: none; +} + +.simplex-preview-qr-toggle:hover { + text-decoration: underline; +} + +.simplex-preview-qr-popup { + flex-direction: column; + align-items: center; + gap: 8px; +} + +.simplex-preview-qr-popup canvas { + border-radius: 8px; +} + +.simplex-preview-qr-caption { + font-size: 14px; + text-align: center; + margin: 0; +} + +.simplex-preview-badges { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + margin: 0 0 6px; +} + +.simplex-preview-badges a { + display: block; +} + +.simplex-preview-badges a img { + height: 40px; + width: auto; + display: block; +} + +.simplex-preview-copy-action { + font-size: 14px; + margin: 0; +} + +.simplex-preview-copy-action a { + color: var(--sp-link); + text-decoration: none; + cursor: pointer; +} + +.simplex-preview-copy-action a:hover { + text-decoration: underline; +} + +.simplex-preview-step-title { + font-size: 14px; + text-align: center; + margin: 0 0 -8px; +} + +.simplex-preview-open-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + background: var(--sp-btn); + color: var(--sp-btn-text, #fff); + border: none; + border-radius: 34px; + padding: 16px 12px 16px 18px; + height: 44px; + font-size: 16px; + line-height: 19px; + letter-spacing: 0.02em; + cursor: pointer; + text-decoration: none; + font-family: inherit; + margin-top: 3px; +} + +.simplex-preview-open-btn svg { + width: 22px; + height: 22px; + flex-shrink: 0; + margin-left: 6px; +} + + +.simplex-preview-open-btn:hover { + background: var(--sp-btn-hover); +} + +@media (min-width: 1000px) { + .simplex-preview-info { + width: 320px; + flex-shrink: 0; + border-left: 1px solid var(--sp-border); + padding: 24px; + } + .simplex-preview-header .simplex-preview-join-btn { + display: none; + } +} + +@media (max-width: 999px) { + .simplex-preview-container { + font-size: 17px; + } + .simplex-preview-main { + max-width: none; + } + .simplex-preview-info { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + padding: 16px; + } + .simplex-preview-info.open { + display: block; + } + .simplex-preview-info-close { + display: block; + position: absolute; + top: 12px; + right: 12px; + background: none; + border: none; + font-size: 24px; + color: var(--sp-text-secondary); + cursor: pointer; + padding: 4px 8px; + line-height: 1; + } + .simplex-preview-info-content { + padding-top: 32px; + } + .simplex-preview-header { + cursor: pointer; + } +} +`; + +const DEFAULT_AVATAR = 'data:image/svg+xml,' + encodeURIComponent(''); + +const IMAGE_PLACEHOLDER_SVG = ``; + +function isDataImage(src) { + return typeof src === 'string' && src.startsWith('data:image/'); +} + +function tailSvg() { + return ''; +} + +var _logoId = 0; +var _svgParser = new DOMParser(); + +function appendSimplexLogo(el) { + var n = _logoId++; + var darkSvg = '' + + '' + + '' + + ''; + var lightSvg = '' + + '' + + '' + + ''; + el.appendChild(document.importNode(_svgParser.parseFromString(darkSvg, 'image/svg+xml').documentElement, true)); + el.appendChild(document.importNode(_svgParser.parseFromString(lightSvg, 'image/svg+xml').documentElement, true)); +} + +const FILE_ICON_SVG = ``; + +const VOICE_ICON_SVG = ``; + +const FORWARD_ICON_SVG = ``; + +const COPY_ICON_SVG = ``; + +function initChannelPreview(container) { + const relayDomains = (container.dataset.relayDomains || '').split(',').map(u => u.trim()).filter(Boolean); + const relayScheme = container.dataset.relayScheme || 'https'; + const channelId = container.dataset.channelId || ''; + const channelLink = container.dataset.channelLink || ''; + const showAppBadges = container.dataset.appDownloadButtons !== 'off'; + const colorScheme = container.dataset.colorScheme || 'light'; + + if (!relayDomains.length || !channelId) { + container.innerHTML = '

Missing configuration: data-relay-domains and data-channel-id required.

'; + return; + } + + injectStyles(); + container.classList.add('simplex-preview-container', 'simplex-scheme-' + colorScheme); + if (container.dataset.lightBackground) { + container.style.setProperty('--sp-light-bg', container.dataset.lightBackground); + } + if (container.dataset.darkBackground) { + container.style.setProperty('--sp-dark-bg', container.dataset.darkBackground); + } + container.innerHTML = '

Loading channel...

'; + + fetchPreview(relayScheme, relayDomains, channelLink, channelId).then(data => { + if (data === 'link_mismatch') { + container.innerHTML = '

All relays returned a different channel link from specified in the page.

'; + return; + } + if (!data) { + container.innerHTML = '

Failed to load channel preview.

'; + return; + } + render(container, data, channelLink, showAppBadges); + }); +} + +let stylesInjected = false; +function injectStyles() { + if (stylesInjected) return; + stylesInjected = true; + const style = document.createElement('style'); + style.textContent = STYLE; + document.head.appendChild(style); +} + +async function fetchPreview(relayScheme, relayDomains, channelLink, channelId) { + let linkMismatch = false; + for (const domain of relayDomains) { + try { + const url = `${relayScheme}://${domain}/channel/${channelId}.json`; + const resp = await fetch(url); + if (!resp.ok) continue; + const data = await resp.json(); + const relayLink = data.channel?.publicGroup?.groupLink; + if (channelLink && relayLink && channelLink !== relayLink) { + linkMismatch = true; + continue; + } + return data; + } catch(e) { + continue; + } + } + return linkMismatch ? 'link_mismatch' : null; +} + +function render(container, data, channelLink, showAppBadges) { + const { channel, members, messages } = data; + const membersMap = {}; + for (const m of members) { + membersMap[m.memberId] = m; + } + + container.innerHTML = ''; + + const main = document.createElement('div'); + main.className = 'simplex-preview-main'; + + const header = renderHeader(channel, channelLink, data.subscribers); + main.appendChild(header); + + const messagesDiv = document.createElement('div'); + messagesDiv.className = 'simplex-preview-messages'; + const welcome = data.welcomeMessage || channel.description; + var allMessages = messages; + if (welcome) { + var welcomeMsg = { + sender: null, + ts: messages.length > 0 ? messages[0].ts : new Date().toISOString(), + content: { type: 'text', text: typeof welcome === 'string' ? welcome : '' }, + formattedText: Array.isArray(welcome) ? welcome : null, + reactions: [] + }; + allMessages = [welcomeMsg].concat(messages); + } + renderMessages(messagesDiv, allMessages, membersMap, channel); + main.appendChild(messagesDiv); + + container.appendChild(main); + + const info = document.createElement('div'); + info.className = 'simplex-preview-info'; + + const closeBtn = document.createElement('button'); + closeBtn.className = 'simplex-preview-info-close'; + closeBtn.innerHTML = '✕'; + info.appendChild(closeBtn); + + const infoContent = document.createElement('div'); + infoContent.className = 'simplex-preview-info-content'; + renderInfoContent(infoContent, data, channelLink, data.subscribers, showAppBadges); + info.appendChild(infoContent); + + container.appendChild(info); + + header.addEventListener('click', (e) => { + if (e.target.closest('.simplex-preview-join-btn')) return; + if (window.innerWidth < 1000) { + info.classList.add('open'); + main.style.overflow = 'hidden'; + } + }); + + closeBtn.addEventListener('click', () => { + info.classList.remove('open'); + main.style.overflow = ''; + }); + + setupSecretToggles(container); + setTimeout(() => { main.scrollTop = main.scrollHeight; }, 0); +} + +function renderHeader(channel, channelLink, subscriberCount) { + const header = document.createElement('div'); + header.className = 'simplex-preview-header'; + + const avatar = document.createElement('img'); + avatar.className = 'simplex-preview-header-avatar'; + avatar.src = isDataImage(channel.image) ? channel.image : DEFAULT_AVATAR; + avatar.alt = channel.displayName; + header.appendChild(avatar); + + const info = document.createElement('div'); + info.className = 'simplex-preview-header-info'; + + const name = document.createElement('h1'); + name.className = 'simplex-preview-header-name'; + name.textContent = channel.displayName; + info.appendChild(name); + + if (subscriberCount > 0) { + const desc = document.createElement('p'); + desc.className = 'simplex-preview-header-description'; + desc.textContent = subscriberCount + ' subscribers'; + info.appendChild(desc); + } + + header.appendChild(info); + + if (channelLink) { + const btn = document.createElement('a'); + btn.className = 'simplex-preview-join-btn'; + btn.textContent = 'Join'; + appendSimplexLogo(btn); + btn.href = channelLink; + header.appendChild(btn); + } + + return header; +} + +function renderInfoContent(container, data, channelLink, subscriberCount, showAppBadges) { + const { channel } = data; + + const avatar = document.createElement('img'); + avatar.className = 'simplex-preview-info-avatar'; + avatar.src = isDataImage(channel.image) ? channel.image : DEFAULT_AVATAR; + avatar.alt = channel.displayName; + container.appendChild(avatar); + + const name = document.createElement('h2'); + name.className = 'simplex-preview-info-name'; + name.textContent = channel.displayName; + container.appendChild(name); + + const shortDescr = data.shortDescription || channel.shortDescr; + if (shortDescr) { + const descrDiv = document.createElement('div'); + descrDiv.className = 'simplex-preview-info-descr'; + descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr) : escapeHtml(shortDescr); + container.appendChild(descrDiv); + } + + if (subscriberCount > 0) { + const subs = document.createElement('p'); + subs.className = 'simplex-preview-info-subscribers'; + subs.textContent = subscriberCount + ' subscribers'; + container.appendChild(subs); + } + + if (channelLink) { + if (!isMobile.any()) { + const openBtn = document.createElement('a'); + openBtn.className = 'simplex-preview-open-btn'; + openBtn.style.display = 'flex'; + openBtn.style.width = 'fit-content'; + openBtn.style.margin = '32px auto 0'; + openBtn.textContent = 'Join in SimpleX Chat'; + appendSimplexLogo(openBtn); + openBtn.href = channelLink; + container.appendChild(openBtn); + } + + const showJoinSection = !isMobile.any() || showAppBadges; + if (showJoinSection) { + const divider = document.createElement('div'); + divider.className = 'simplex-preview-divider'; + container.appendChild(divider); + + const joinTitle = document.createElement('p'); + joinTitle.className = 'simplex-preview-conversion-title'; + joinTitle.textContent = 'To join this channel'; + container.appendChild(joinTitle); + } + + const conversion = document.createElement('div'); + conversion.className = 'simplex-preview-conversion'; + if (!showJoinSection) { + conversion.style.marginTop = '28px'; + } + if (isMobile.any()) { + renderMobileConversion(conversion, channelLink, showAppBadges); + } else { + renderDesktopConversion(conversion, channelLink, showAppBadges); + } + container.appendChild(conversion); + } +} + +var BADGE_APPLE = 'App Store'; +var BADGE_GOOGLE = 'Google Play'; +var BADGE_FDROID = 'F-Droid'; +var BADGE_APK = 'APK Download'; +var BADGE_TESTFLIGHT = 'TestFlight'; + +function renderAppBadges(container) { + const title = document.createElement('p'); + title.className = 'simplex-preview-step-title'; + title.textContent = 'Install SimpleX Chat app'; + container.appendChild(title); + + const badges = document.createElement('div'); + badges.className = 'simplex-preview-badges'; + if (isMobile.Android()) { + badges.innerHTML = BADGE_GOOGLE + BADGE_FDROID + BADGE_APK; + } else if (isMobile.iOS()) { + badges.innerHTML = BADGE_APPLE + BADGE_TESTFLIGHT; + } else { + badges.innerHTML = BADGE_APPLE + BADGE_GOOGLE; + } + container.appendChild(badges); +} + +function renderDesktopConversion(container, channelLink, showAppBadges) { + if (showAppBadges) { + renderAppBadges(container); + } + + const qrToggle = document.createElement('a'); + qrToggle.className = 'simplex-preview-qr-toggle'; + qrToggle.textContent = 'Show QR code for mobile app'; + qrToggle.href = '#'; + container.appendChild(qrToggle); + + const qrPopup = document.createElement('div'); + qrPopup.className = 'simplex-preview-qr-popup'; + qrPopup.style.display = 'none'; + + const caption = document.createElement('p'); + caption.className = 'simplex-preview-qr-caption'; + caption.textContent = 'Scan from SimpleX Chat app'; + qrPopup.appendChild(caption); + + const canvas = document.createElement('canvas'); + qrPopup.appendChild(canvas); + + const qrHide = document.createElement('a'); + qrHide.className = 'simplex-preview-qr-toggle'; + qrHide.textContent = 'Hide QR code'; + qrHide.href = '#'; + qrPopup.appendChild(qrHide); + container.appendChild(qrPopup); + + function toggleQr(e) { + e.preventDefault(); + if (qrPopup.style.display === 'none') { + qrPopup.style.display = 'flex'; + qrToggle.style.display = 'none'; + if (!canvas._rendered) { + canvas._rendered = true; + try { + var cs = getComputedStyle(container); + QRCode.toCanvas(canvas, channelLink, { + errorCorrectionLevel: 'M', + color: { + dark: cs.getPropertyValue('--sp-qr-fg').trim() || '#062D56', + light: cs.getPropertyValue('--sp-qr-bg').trim() || '#ffffff' + }, + width: 400, + margin: 1 + }).then(function() { + canvas.style.width = '200px'; + canvas.style.height = '200px'; + }).catch(function() { + qrPopup.style.display = 'none'; + qrToggle.style.display = 'none'; + }); + } catch(err) { + qrPopup.style.display = 'none'; + qrToggle.style.display = 'none'; + } + } + } else { + qrPopup.style.display = 'none'; + qrToggle.style.display = ''; + } + } + qrToggle.addEventListener('click', toggleQr); + qrHide.addEventListener('click', toggleQr); + + const copyAction = document.createElement('p'); + copyAction.className = 'simplex-preview-copy-action'; + const copyLink = document.createElement('a'); + copyLink.textContent = 'copy link'; + copyLink.addEventListener('click', function() { + navigator.clipboard.writeText(channelLink).then(function() { + copyLink.textContent = 'Copied!'; + setTimeout(function() { copyLink.textContent = 'copy link'; }, 2000); + }); + }); + copyAction.appendChild(document.createTextNode('Or ')); + copyAction.appendChild(copyLink); + copyAction.appendChild(document.createTextNode(' for desktop app')); + container.appendChild(copyAction); +} + +function renderMobileConversion(container, channelLink, showAppBadges) { + if (showAppBadges) { + renderAppBadges(container); + } + + const openBtn = document.createElement('a'); + openBtn.className = 'simplex-preview-open-btn'; + openBtn.textContent = 'Join in SimpleX Chat'; + appendSimplexLogo(openBtn); + openBtn.href = channelLink; + container.appendChild(openBtn); +} + + +function setupSecretToggles(container) { + container.addEventListener('click', (e) => { + const secret = e.target.closest('.secret'); + if (secret) secret.classList.toggle('visible'); + }); +} + +function renderMessages(container, messages, membersMap, channel) { + const hasAnySender = messages.some(function(m) { return m.sender; }); + let prevMsg = null; + let prevDate = null; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + const nextMsg = i < messages.length - 1 ? messages[i + 1] : null; + + const msgDate = formatDateLabel(msg.ts); + if (msgDate !== prevDate) { + const dateSep = document.createElement('div'); + dateSep.className = 'simplex-preview-date-separator'; + dateSep.textContent = msgDate; + container.appendChild(dateSep); + prevDate = msgDate; + } + + const separation = getItemSeparation(msg, prevMsg); + const nextSeparation = getItemSeparation(nextMsg, msg); + const showAvatar = hasAnySender && (!prevMsg || msg.sender !== prevMsg.sender); + const showName = showAvatar; + const showTail = nextSeparation.largeGap; + + const member = msg.sender ? membersMap[msg.sender] : null; + const senderName = member ? member.displayName : channel.displayName; + const senderImage = member ? member.image : channel.image; + + const row = document.createElement('div'); + row.className = 'simplex-preview-msg-row' + (nextSeparation.largeGap ? ' has-gap' : ''); + + if (hasAnySender) { + if (showName) { + const nameDiv = document.createElement('div'); + nameDiv.className = 'simplex-preview-msg-name'; + nameDiv.textContent = senderName; + container.appendChild(nameDiv); + } + + if (showAvatar) { + const avatarImg = document.createElement('img'); + avatarImg.className = 'simplex-preview-msg-avatar'; + avatarImg.src = isDataImage(senderImage) ? senderImage : DEFAULT_AVATAR; + avatarImg.alt = senderName; + row.appendChild(avatarImg); + } else { + const spacer = document.createElement('div'); + spacer.className = 'simplex-preview-msg-avatar-placeholder'; + row.appendChild(spacer); + } + } + + const col = document.createElement('div'); + const bubble = renderBubble(msg, member, showTail, membersMap, channel); + col.appendChild(bubble); + + if (msg.reactions && msg.reactions.length > 0) { + col.appendChild(renderReactions(msg.reactions)); + } + + row.appendChild(col); + container.appendChild(row); + prevMsg = msg; + } +} + +function renderBubble(msg, member, showTail, membersMap, channel) { + const mc = msg.content; + const mediaOnly = (mc.type === 'image' || mc.type === 'video') && !mc.text && !msg.quote && !msg.forward; + const noTailContent = (mc.type === 'image' || mc.type === 'video' || mc.type === 'voice') && !mc.text; + const hasTail = showTail && !noTailContent; + + const bubble = document.createElement('div'); + bubble.className = 'simplex-preview-bubble' + (hasTail ? ' has-tail' : '') + (mediaOnly ? ' media-only' : ''); + + if (hasTail) { + const tail = document.createElement('div'); + tail.className = 'simplex-preview-bubble-tail'; + tail.innerHTML = tailSvg(); + bubble.appendChild(tail); + } + + const inner = document.createElement('div'); + inner.className = 'simplex-preview-bubble-inner'; + + if (msg.forward) { + const fwd = document.createElement('div'); + fwd.className = 'simplex-preview-forwarded-header'; + fwd.innerHTML = FORWARD_ICON_SVG + ' Forwarded'; + inner.appendChild(fwd); + } + + if (msg.quote) { + inner.appendChild(renderQuote(msg.quote, membersMap, channel)); + } + + switch (mc.type) { + case 'image': + renderImageContent(inner, mc, msg, mediaOnly); + break; + case 'video': + renderVideoContent(inner, mc, msg, mediaOnly); + break; + case 'link': + renderLinkContent(inner, mc, msg); + break; + case 'voice': + renderVoiceContent(inner, mc, msg); + break; + case 'file': + renderFileContent(inner, mc, msg); + break; + default: + renderTextContent(inner, msg); + break; + } + + bubble.appendChild(inner); + + if (mediaOnly) { + const overlay = document.createElement('div'); + overlay.className = 'simplex-preview-meta-overlay'; + if (msg.edited) overlay.innerHTML = 'edited '; + overlay.innerHTML += formatTime(msg.ts); + bubble.appendChild(overlay); + } + + return bubble; +} + +function renderQuote(quote, membersMap, channel) { + const quoteDiv = document.createElement('div'); + quoteDiv.className = 'simplex-preview-quote'; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'simplex-preview-quote-content'; + + const ref = quote.msgRef; + let senderName = ''; + if (ref) { + if (ref.memberId) { + const quotedMember = membersMap[ref.memberId]; + senderName = quotedMember ? quotedMember.displayName : ''; + } else if (ref.sent) { + senderName = channel.displayName; + } + } + if (senderName) { + const sender = document.createElement('div'); + sender.className = 'simplex-preview-quote-sender'; + sender.textContent = senderName; + contentDiv.appendChild(sender); + } + + const textDiv = document.createElement('div'); + textDiv.className = 'simplex-preview-quote-text'; + textDiv.textContent = quote.content ? (quote.content.text || '') : ''; + contentDiv.appendChild(textDiv); + + quoteDiv.appendChild(contentDiv); + + if (quote.content) { + if ((quote.content.type === 'image' || quote.content.type === 'video') && isDataImage(quote.content.image)) { + const thumb = document.createElement('img'); + thumb.className = 'simplex-preview-quote-thumb'; + thumb.src = quote.content.image; + quoteDiv.appendChild(thumb); + } else if (quote.content.type === 'file') { + const icon = document.createElement('div'); + icon.className = 'simplex-preview-quote-file-icon'; + icon.innerHTML = FILE_ICON_SVG; + quoteDiv.appendChild(icon); + } else if (quote.content.type === 'voice') { + const icon = document.createElement('div'); + icon.className = 'simplex-preview-quote-file-icon'; + icon.innerHTML = VOICE_ICON_SVG; + quoteDiv.appendChild(icon); + } + } + + return quoteDiv; +} + +function classifyImage(img) { + const w = img.naturalWidth; + const h = img.naturalHeight; + img.classList.add(w * 0.97 <= h ? 'portrait' : 'landscape'); +} + +function renderImageContent(inner, mc, msg, mediaOnly) { + if (isDataImage(mc.image)) { + const img = document.createElement('img'); + img.className = 'simplex-preview-image'; + img.src = mc.image; + img.alt = 'Image'; + img.addEventListener('load', () => classifyImage(img)); + inner.appendChild(img); + } else { + const ph = document.createElement('div'); + ph.className = 'simplex-preview-image-placeholder'; + ph.innerHTML = IMAGE_PLACEHOLDER_SVG; + inner.appendChild(ph); + } + if (mc.text) { + appendTextBlock(inner, msg); + } else if (!mediaOnly) { + appendMetaOnly(inner, msg); + } +} + +function renderVideoContent(inner, mc, msg, mediaOnly) { + if (isDataImage(mc.image)) { + const wrapper = document.createElement('div'); + wrapper.style.position = 'relative'; + const img = document.createElement('img'); + img.className = 'simplex-preview-image'; + img.src = mc.image; + img.alt = 'Video'; + img.addEventListener('load', () => classifyImage(img)); + wrapper.appendChild(img); + const dur = document.createElement('span'); + dur.style.cssText = 'position:absolute;bottom:6px;left:12px;color:#fff;font-size:12px;text-shadow:0 0 4px rgba(0,0,0,0.7),0 0 2px rgba(0,0,0,0.9);'; + dur.textContent = formatDuration(mc.duration || 0); + wrapper.appendChild(dur); + inner.appendChild(wrapper); + } else { + const ph = document.createElement('div'); + ph.className = 'simplex-preview-image-placeholder'; + ph.innerHTML = IMAGE_PLACEHOLDER_SVG; + inner.appendChild(ph); + } + if (mc.text) { + appendTextBlock(inner, msg); + } else if (!mediaOnly) { + appendMetaOnly(inner, msg); + } +} + +function renderLinkContent(bubble, mc, msg) { + if (mc.preview) { + const card = document.createElement('div'); + card.className = 'simplex-preview-link-card'; + if (isDataImage(mc.preview.image)) { + const img = document.createElement('img'); + img.className = 'simplex-preview-link-card-image'; + img.src = mc.preview.image; + img.alt = mc.preview.title || ''; + card.appendChild(img); + } + const body = document.createElement('div'); + body.className = 'simplex-preview-link-card-body'; + if (mc.preview.title) { + const title = document.createElement('div'); + title.className = 'simplex-preview-link-card-title'; + title.textContent = mc.preview.title; + body.appendChild(title); + } + if (mc.preview.description) { + const desc = document.createElement('div'); + desc.className = 'simplex-preview-link-card-description'; + desc.textContent = mc.preview.description; + body.appendChild(desc); + } + if (mc.preview.uri) { + const uri = document.createElement('div'); + uri.className = 'simplex-preview-link-card-uri'; + uri.textContent = mc.preview.uri; + body.appendChild(uri); + } + card.appendChild(body); + bubble.appendChild(card); + } + appendTextBlock(bubble, msg); +} + +function renderVoiceContent(bubble, mc, msg) { + const voiceDiv = document.createElement('div'); + voiceDiv.className = 'simplex-preview-voice'; + voiceDiv.innerHTML = VOICE_ICON_SVG + ' ' + formatDuration(mc.duration || 0) + ''; + bubble.appendChild(voiceDiv); + if (mc.text) { + appendTextBlock(bubble, msg); + } else { + appendMetaOnly(bubble, msg); + } +} + +function renderFileContent(bubble, mc, msg) { + const fileDiv = document.createElement('div'); + fileDiv.className = 'simplex-preview-file-indicator'; + fileDiv.innerHTML = FILE_ICON_SVG; + const info = document.createElement('div'); + if (msg.file) { + const nameSpan = document.createElement('div'); + nameSpan.className = 'simplex-preview-file-name'; + nameSpan.textContent = msg.file.fileName; + info.appendChild(nameSpan); + const sizeSpan = document.createElement('div'); + sizeSpan.className = 'simplex-preview-file-size'; + sizeSpan.textContent = formatFileSize(msg.file.fileSize); + info.appendChild(sizeSpan); + } + fileDiv.appendChild(info); + bubble.appendChild(fileDiv); + if (mc.text) { + appendTextBlock(bubble, msg); + } else { + appendMetaOnly(bubble, msg); + } +} + +function renderTextContent(bubble, msg) { + appendTextBlock(bubble, msg); +} + +function appendTextBlock(bubble, msg) { + const textDiv = document.createElement('div'); + textDiv.className = 'simplex-preview-text'; + const meta = renderMetaHTML(msg); + if (msg.formattedText && msg.formattedText.length > 0) { + textDiv.innerHTML = renderMarkdown(msg.formattedText) + meta; + } else { + textDiv.innerHTML = escapeHtml(msg.content.text || '') + meta; + } + bubble.appendChild(textDiv); +} + +function appendMetaOnly(bubble, msg) { + const metaDiv = document.createElement('div'); + metaDiv.style.cssText = 'padding: 0 8px 4px; text-align: right;'; + metaDiv.innerHTML = renderMetaHTML(msg); + bubble.appendChild(metaDiv); +} + +function renderMetaHTML(msg) { + let html = ''; + if (msg.edited) html += 'edited '; + html += formatTime(msg.ts); + html += ''; + return html; +} + +function renderReactions(reactions) { + const div = document.createElement('div'); + div.className = 'simplex-preview-reactions'; + for (const r of reactions) { + if (r.totalReacted < 1) continue; + const badge = document.createElement('span'); + badge.className = 'simplex-preview-reaction'; + const emoji = r.reaction && r.reaction.emoji ? r.reaction.emoji : '?'; + badge.appendChild(document.createTextNode(emoji)); + if (r.totalReacted > 1) { + const count = document.createElement('span'); + count.className = 'simplex-preview-reaction-count'; + count.textContent = r.totalReacted; + badge.appendChild(count); + } + div.appendChild(badge); + } + return div; +} + +function getItemSeparation(msg, prevMsg) { + if (!prevMsg || !msg) return { largeGap: true }; + const sameSender = msg.sender === prevMsg.sender; + if (!sameSender) return { largeGap: true }; + const t1 = new Date(prevMsg.ts).valueOf(); + const t2 = new Date(msg.ts).valueOf(); + if (Math.abs(t2 - t1) >= 60000) return { largeGap: true }; + return { largeGap: false }; +} + +function formatTime(ts) { + try { + const d = new Date(ts); + const h = d.getHours().toString().padStart(2, '0'); + const m = d.getMinutes().toString().padStart(2, '0'); + return h + ':' + m; + } catch(e) { + return ''; + } +} + +function formatDateLabel(ts) { + try { + const d = new Date(ts); + const now = new Date(); + const weekday = d.toLocaleDateString(undefined, { weekday: 'short' }); + const dayMonth = d.toLocaleDateString(undefined, { + day: 'numeric', + month: 'short', + year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined + }); + return weekday + ', ' + dayMonth; + } catch(e) { + return ''; + } +} + +function formatDuration(secs) { + const m = Math.floor(secs / 60); + const s = secs % 60; + return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0'); +} + +function formatFileSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; +} + +document.querySelectorAll('[data-simplex-channel-preview]').forEach(initChannelPreview); + +})(); diff --git a/website/src/js/directory.js b/website/src/js/directory.jsc similarity index 77% rename from website/src/js/directory.js rename to website/src/js/directory.jsc index afaac1053f..6959cd41a5 100644 --- a/website/src/js/directory.js +++ b/website/src/js/directory.jsc @@ -1,3 +1,11 @@ +#include "simplex-lib.jsc" + +const simplexDirectoryDataURL = 'https://directory.simplex.chat/data/'; + +// const simplexDirectoryDataURL = 'http://localhost:8080/directory-data/'; + +const simplexUsersGroup = 'SimpleX users group'; + (function() { if (!document.location.pathname.startsWith('/directory')) return; @@ -428,144 +436,4 @@ if (document.readyState === 'loading') { } else { initDirectory(); } - -function escapeHtml(text) { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(/\n/g, "
"); -} - -function getSimplexLinkDescr(linkType) { - switch (linkType) { - case 'contact': return 'SimpleX contact address'; - case 'invitation': return 'SimpleX one-time invitation'; - case 'group': return 'SimpleX group link'; - case 'channel': return 'SimpleX channel link'; - case 'relay': return 'SimpleX relay link'; - default: return 'SimpleX link'; - } -} - -function viaHost(smpHosts) { - const first = smpHosts[0] ?? '?'; - return `via ${first}`; -} - -function isCurrentSite(uri) { - return uri.startsWith("https://simplex.chat") || uri.startsWith("https://www.simplex.chat") -} - -function targetBlank(uri) { - return isCurrentSite(uri) ? '' : ' target="_blank"' -} - -function renderMarkdown(fts) { - let html = ''; - for (const ft of fts) { - const { format, text } = ft; - if (!format) { - html += escapeHtml(text); - continue; - } - try { - switch (format.type) { - case 'bold': - html += `${escapeHtml(text)}`; - break; - case 'italic': - html += `${escapeHtml(text)}`; - break; - case 'strikeThrough': - html += `${escapeHtml(text)}`; - break; - case 'snippet': - html += `${escapeHtml(text)}`; - break; - case 'secret': - html += `${escapeHtml(text)}`; - break; - case 'small': - html += `${escapeHtml(text)}`; - break; - case 'colored': - html += `${escapeHtml(text)}`; - break; - case 'uri': - let href = text.startsWith('http://') || text.startsWith('https://') || text.startsWith('simplex:/') ? text : 'https://' + text; - html += `${escapeHtml(text)}`; - break; - case 'hyperLink': { - const { showText, linkUri } = format; - html += `${escapeHtml(showText ?? linkUri)}`; - break; - } - case 'simplexLink': { - const { showText, linkType, simplexUri, smpHosts } = format; - const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType); - html += `${linkText} (${viaHost(smpHosts)})`; - break; - } - case 'command': - html += `${escapeHtml(text)}`; - break; - case 'mention': - html += `${escapeHtml(text)}`; - break; - case 'email': - html += `${escapeHtml(text)}`; - break; - case 'phone': - html += `${escapeHtml(text)}`; - break; - case 'unknown': - html += escapeHtml(text); - break; - default: - html += escapeHtml(text); - } - } catch(e) { - console.log(e); - html += escapeHtml(text); - } - } - return html; -} })(); - -const simplexDirectoryDataURL = 'https://directory.simplex.chat/data/'; - -// const simplexDirectoryDataURL = 'http://localhost:8080/directory-data/'; - -const simplexUsersGroup = 'SimpleX users group'; - -const simplexAddressRegexp = /^simplex:\/([a-z]+)#(.+)/i; - -const simplexShortLinkTypes = ["a", "c", "g", "i", "r"]; - -function platformSimplexUri(uri) { - if (isMobile.any()) return uri; - const res = uri.match(simplexAddressRegexp); - if (!res || !Array.isArray(res) || res.length < 3) return uri; - const linkType = res[1]; - const fragment = res[2]; - if (simplexShortLinkTypes.includes(linkType)) { - const queryIndex = fragment.indexOf('?'); - if (queryIndex === -1) return uri; - const hashPart = fragment.substring(0, queryIndex); - const queryStr = fragment.substring(queryIndex + 1); - const params = new URLSearchParams(queryStr); - const host = params.get('h'); - if (!host) return uri; - params.delete('h'); - let newFragment = hashPart; - const remainingParams = params.toString(); - if (remainingParams) newFragment += '?' + remainingParams; - return `https://${host}:/${linkType}#${newFragment}`; - } else { - return `https://simplex.chat/${linkType}#${fragment}`; - } -} diff --git a/website/src/js/simplex-lib.jsc b/website/src/js/simplex-lib.jsc new file mode 100644 index 0000000000..ffec278ca2 --- /dev/null +++ b/website/src/js/simplex-lib.jsc @@ -0,0 +1,156 @@ +const isMobile = { + Android: () => navigator.userAgent.match(/Android/i), + iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i), + any: () => navigator.userAgent.match(/Android|iPhone|iPad|iPod/i) +}; + +function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/\n/g, "
"); +} + +function escapeAttr(text) { + return text + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} + +const SAFE_URI_SCHEME = /^(https?:|simplex:|mailto:|tel:)/i; + +function safeHref(uri) { + if (SAFE_URI_SCHEME.test(uri)) return escapeAttr(uri); + return escapeAttr(`javascript:void(alert('Potentially malicious link blocked:\\n'+${JSON.stringify(uri)}))`); +} + +function getSimplexLinkDescr(linkType) { + switch (linkType) { + case 'contact': return 'SimpleX contact address'; + case 'invitation': return 'SimpleX one-time invitation'; + case 'group': return 'SimpleX group link'; + case 'channel': return 'SimpleX channel link'; + case 'relay': return 'SimpleX relay link'; + default: return 'SimpleX link'; + } +} + +function viaHost(smpHosts) { + const first = smpHosts[0] ?? '?'; + return `via ${first}`; +} + +function isCurrentSite(uri) { + return uri.startsWith("https://simplex.chat") || uri.startsWith("https://www.simplex.chat") +} + +function targetBlank(uri) { + return isCurrentSite(uri) ? '' : ' target="_blank"' +} + +function renderMarkdown(fts) { + let html = ''; + for (const ft of fts) { + const { format, text } = ft; + if (!format) { + html += escapeHtml(text); + continue; + } + try { + switch (format.type) { + case 'bold': + html += `${escapeHtml(text)}`; + break; + case 'italic': + html += `${escapeHtml(text)}`; + break; + case 'strikeThrough': + html += `${escapeHtml(text)}`; + break; + case 'snippet': + html += `${escapeHtml(text)}`; + break; + case 'secret': + html += `${escapeHtml(text)}`; + break; + case 'small': + html += `${escapeHtml(text)}`; + break; + case 'colored': + html += `${escapeHtml(text)}`; + break; + case 'uri': { + let href = text.startsWith('http://') || text.startsWith('https://') || text.startsWith('simplex:/') ? text : 'https://' + text; + html += `${escapeHtml(text)}`; + break; + } + case 'hyperLink': { + const { showText, linkUri } = format; + html += `${escapeHtml(showText ?? linkUri)}`; + break; + } + case 'simplexLink': { + const { showText, linkType, simplexUri, smpHosts } = format; + const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType); + html += `${linkText} (${escapeHtml(viaHost(smpHosts))})`; + break; + } + case 'command': + html += `${escapeHtml(text)}`; + break; + case 'mention': + html += `${escapeHtml(text)}`; + break; + case 'email': + html += `${escapeHtml(text)}`; + break; + case 'phone': + html += `${escapeHtml(text)}`; + break; + case 'unknown': + html += escapeHtml(text); + break; + default: + html += escapeHtml(text); + } + } catch(e) { + console.log(e); + html += escapeHtml(text); + } + } + return html; +} + +const simplexAddressRegexp = /^simplex:\/([a-z]+)#(.+)/i; + +const simplexShortLinkTypes = ["a", "c", "g", "i", "r"]; + +function platformSimplexUri(uri) { + if (isMobile.any()) return uri; + const res = uri.match(simplexAddressRegexp); + if (!res || !Array.isArray(res) || res.length < 3) return uri; + const linkType = res[1]; + const fragment = res[2]; + if (simplexShortLinkTypes.includes(linkType)) { + const queryIndex = fragment.indexOf('?'); + if (queryIndex === -1) return uri; + const hashPart = fragment.substring(0, queryIndex); + const queryStr = fragment.substring(queryIndex + 1); + const params = new URLSearchParams(queryStr); + const host = params.get('h'); + if (!host) return uri; + params.delete('h'); + let newFragment = hashPart; + const remainingParams = params.toString(); + if (remainingParams) newFragment += '?' + remainingParams; + return `https://${host}:/${linkType}#${newFragment}`; + } else { + return `https://simplex.chat/${linkType}#${fragment}`; + } +} diff --git a/website/web.sh b/website/web.sh index 9464982a45..49888f78aa 100755 --- a/website/web.sh +++ b/website/web.sh @@ -55,6 +55,10 @@ for lang in "${langs[@]}"; do echo "done $lang copying" done +for f in src/js/*.jsc; do + [ -f "$f" ] && cpp -P -traditional-cpp "$f" "${f%.jsc}.js" +done + npm run build for lang in "${langs[@]}"; do