mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-03 09:01:59 +00:00
core: render web previews for channels (#7029)
* plan: web previews for channels
* types for recipient side to support channel web previews and domain names
* fix
* migrations
* update schema and api types
* update schema
* rename migrations
* core: render channel preview data
* core: render channel preview data in relays
* website: use cpp to inject JS functions
* JSC files
* remove directory.js
* channel preview renderer
* Revert "cli: fix redraw slowness (#6735)"
This reverts commit b801d77c74.
* sample channel page
* default avatar
* rename options
* better layout
* layout
* images
* some fixes
* tails
* markdown colors
* image sizes
* reactions
* fix reactions
* fewer avatars
* forward icon
* command to change group access parameters
* view public group access changes in CLI
* media metadata color
* ios: group web access ui
* update ui
* add init
* kotlin, labels
* update page
* update relay base URL
* fix
* ios update channel web page info
* update kotlin layout
* use cards
* update layout
* use domains for relay data, path is fixed
* update embed code
* fix bots api
* include only history items and senders
* update preview JS/HTML
* show different error if link is different
* remove stale json files
* better layout
* layout fixes
* improve layout
* improve layout
* update embed code
* web cta
* better layout
* buttons
* layout
* paddings
* desktop cta
* desktop cta
* cta layout
* fonts
* paddings
* paddings
* more paddings
* copy link
* read more
* hide avatar and placeholder when all messages are from channel
* color scheme
* fix color
* improve
* layout
* welcome message
* dark mode colors
* padding
* font size
* overscroll
* font
* logo on button
* better join
* buttons
* refactor
* another logo
* text
* desktop button
* button text
* center
* fix svg
* padding
* smaller gap
* render channel on any message changes etc
* fixes
* atomic file updates, escape attributes
* fix tests
* more tests
* more efficient rendering
* improve security
* sanitize links, include mentioned members
* schema
* fixes
* improve rendering
* fix showing correct subscribers count
* fix member names
---------
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
+4
-1
@@ -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
|
||||
|
||||
@@ -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 = 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 {
|
||||
"""
|
||||
<div data-simplex-channel-preview
|
||||
data-channel-link="\(pg.groupLink)"
|
||||
data-channel-id="\(pg.publicGroupId)"
|
||||
data-relay-domains="\(relayDomains.joined(separator: ","))"
|
||||
data-app-download-buttons="on"
|
||||
data-color-scheme="light"
|
||||
></div>
|
||||
<script src="https://simplex.chat/js/channel-preview.js"></script>
|
||||
"""
|
||||
} 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = "<group>"; };
|
||||
6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = "<group>"; };
|
||||
E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelWebAccessView.swift; sourceTree = "<group>"; };
|
||||
6495D7072F48D0000060512B /* AddGroupRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupRelayView.swift; sourceTree = "<group>"; };
|
||||
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
|
||||
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package chat.simplex.common.views.chat.group
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@Composable
|
||||
fun ChannelWebPageView(
|
||||
rhId: Long?,
|
||||
groupInfo: GroupInfo,
|
||||
chatModel: ChatModel,
|
||||
close: () -> Unit
|
||||
) {
|
||||
val isChannel = groupInfo.isChannel
|
||||
val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess
|
||||
val webPage = rememberSaveable { mutableStateOf(access?.groupWebPage ?: "") }
|
||||
val allowEmbedding = rememberSaveable { mutableStateOf(access?.allowEmbedding ?: false) }
|
||||
val groupRelays = remember { mutableStateListOf<GroupRelay>() }
|
||||
|
||||
val dataUnchanged = webPage.value.trim() == (access?.groupWebPage ?: "") &&
|
||||
allowEmbedding.value == (access?.allowEmbedding ?: false)
|
||||
|
||||
val save: () -> Unit = {
|
||||
withBGApi {
|
||||
val trimmedPage = webPage.value.trim()
|
||||
val newAccess = PublicGroupAccess(
|
||||
groupWebPage = trimmedPage.ifEmpty { null },
|
||||
groupDomain = access?.groupDomain,
|
||||
domainWebPage = access?.domainWebPage ?: false,
|
||||
allowEmbedding = allowEmbedding.value
|
||||
)
|
||||
val gp = groupInfo.groupProfile.copy(
|
||||
publicGroup = groupInfo.groupProfile.publicGroup?.copy(publicGroupAccess = newAccess)
|
||||
)
|
||||
val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, gp, isChannel)
|
||||
if (gInfo != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatsContext.updateGroup(rhId, gInfo)
|
||||
}
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val closeWithAlert = {
|
||||
if (dataUnchanged) {
|
||||
close()
|
||||
} else {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(MR.strings.save_preferences_question),
|
||||
confirmText = generalGetString(if (isChannel) MR.strings.save_and_notify_channel_subscribers else MR.strings.save_and_notify_group_members),
|
||||
dismissText = generalGetString(MR.strings.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = close,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val relays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId)
|
||||
groupRelays.clear()
|
||||
groupRelays.addAll(relays)
|
||||
}
|
||||
|
||||
BackHandler(onBack = closeWithAlert)
|
||||
ModalView(close = closeWithAlert, cardScreen = true) {
|
||||
ChannelWebPageLayout(
|
||||
isChannel = isChannel,
|
||||
webPage = webPage,
|
||||
allowEmbedding = allowEmbedding,
|
||||
groupRelays = groupRelays,
|
||||
groupInfo = groupInfo,
|
||||
dataUnchanged = dataUnchanged,
|
||||
save = save
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelWebPageLayout(
|
||||
isChannel: Boolean,
|
||||
webPage: MutableState<String>,
|
||||
allowEmbedding: MutableState<Boolean>,
|
||||
groupRelays: List<GroupRelay>,
|
||||
groupInfo: GroupInfo,
|
||||
dataUnchanged: Boolean,
|
||||
save: () -> Unit
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(if (isChannel) MR.strings.channel_webpage else MR.strings.group_webpage))
|
||||
|
||||
val embedCode = embedCode(groupRelays, groupInfo)
|
||||
if (embedCode != null) {
|
||||
SectionTextFooter(stringResource(MR.strings.webpage_info))
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(stringResource(MR.strings.webpage_code)) {
|
||||
SectionItemView {
|
||||
Text(
|
||||
embedCode,
|
||||
style = MaterialTheme.typography.body2.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp),
|
||||
maxLines = 6,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
SectionItemView({
|
||||
clipboard.setText(AnnotatedString(embedCode))
|
||||
showToast(generalGetString(MR.strings.copied))
|
||||
}) {
|
||||
Icon(painterResource(MR.images.ic_content_copy), null, tint = MaterialTheme.colors.primary)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(MR.strings.copy_code), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.webpage_code_footer))
|
||||
} else {
|
||||
SectionTextFooter(stringResource(MR.strings.relays_no_web_support))
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(stringResource(MR.strings.enter_webpage_url)) {
|
||||
PlainTextEditor(webPage, placeholder = stringResource(MR.strings.web_page_url_placeholder))
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.webpage_url_footer))
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView {
|
||||
PreferenceToggle(stringResource(MR.strings.allow_anyone_to_embed), checked = allowEmbedding.value) {
|
||||
allowEmbedding.value = it
|
||||
}
|
||||
}
|
||||
SectionTextFooter(stringResource(if (allowEmbedding.value) MR.strings.embed_any_webpage_can_show else MR.strings.embed_only_your_page))
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView {
|
||||
SectionItemView(save, disabled = dataUnchanged) {
|
||||
Text(
|
||||
stringResource(MR.strings.save_verb),
|
||||
color = if (dataUnchanged) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun embedCode(groupRelays: List<GroupRelay>, groupInfo: GroupInfo): String? {
|
||||
val pg = groupInfo.groupProfile.publicGroup ?: return null
|
||||
val relayDomains = groupRelays.mapNotNull { it.relayCap.webDomain }
|
||||
if (relayDomains.isEmpty()) return null
|
||||
val domains = relayDomains.joinToString(",")
|
||||
return """<div data-simplex-channel-preview
|
||||
data-channel-link="${pg.groupLink}"
|
||||
data-channel-id="${pg.publicGroupId}"
|
||||
data-relay-domains="$domains"
|
||||
data-app-download-buttons="on"
|
||||
data-color-scheme="light"
|
||||
></div>
|
||||
<script src="https://simplex.chat/js/channel-preview.js"></script>"""
|
||||
}
|
||||
+22
@@ -175,6 +175,9 @@ fun ModalData.GroupChatInfoView(
|
||||
manageGroupLink = {
|
||||
ModalManager.end.showModal(cardScreen = true) { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) }
|
||||
},
|
||||
manageWebPage = {
|
||||
ModalManager.end.showCustomModal { close -> ChannelWebPageView(rhId, groupInfo, chatModel, close) }
|
||||
},
|
||||
onSearchClicked = onSearchClicked,
|
||||
deletingItems = deletingItems
|
||||
)
|
||||
@@ -506,6 +509,7 @@ fun ModalData.GroupChatInfoLayout(
|
||||
clearChat: () -> Unit,
|
||||
leaveGroup: () -> Unit,
|
||||
manageGroupLink: () -> Unit,
|
||||
manageWebPage: () -> Unit,
|
||||
close: () -> Unit = { ModalManager.closeAllModalsEverywhere()},
|
||||
onSearchClicked: () -> Unit,
|
||||
deletingItems: State<Boolean>
|
||||
@@ -796,6 +800,13 @@ fun ModalData.GroupChatInfoLayout(
|
||||
}
|
||||
}
|
||||
|
||||
if (groupInfo.useRelays && groupInfo.isOwner) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(title = stringResource(MR.strings.advanced_options)) {
|
||||
ChannelWebPageButton(groupInfo, manageWebPage)
|
||||
}
|
||||
}
|
||||
|
||||
if (developerTools) {
|
||||
SectionDividerSpaced()
|
||||
SectionView(title = stringResource(MR.strings.section_title_for_console)) {
|
||||
@@ -1209,6 +1220,16 @@ private fun ChannelLinkButton(onClick: () -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelWebPageButton(groupInfo: GroupInfo, onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_travel_explore),
|
||||
stringResource(if (groupInfo.isChannel) MR.strings.channel_webpage else MR.strings.group_webpage),
|
||||
onClick,
|
||||
iconColor = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelLinkQRCodeSection(groupLink: String) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
@@ -1413,6 +1434,7 @@ fun PreviewGroupChatInfoLayout() {
|
||||
clearChat = {},
|
||||
leaveGroup = {},
|
||||
manageGroupLink = {},
|
||||
manageWebPage = {},
|
||||
onSearchClicked = {},
|
||||
deletingItems = remember { mutableStateOf(true) }
|
||||
)
|
||||
|
||||
+22
@@ -102,6 +102,28 @@ fun TextEditor(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlainTextEditor(
|
||||
value: MutableState<String>,
|
||||
placeholder: String? = null,
|
||||
singleLine: Boolean = true
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value.value,
|
||||
onValueChange = { value.value = it },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = 12.dp),
|
||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
||||
singleLine = singleLine,
|
||||
cursorBrush = SolidColor(MaterialTheme.colors.secondary),
|
||||
decorationBox = { innerTextField ->
|
||||
if (value.value.isEmpty() && placeholder != null) {
|
||||
Text(placeholder, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ParsedFormattedText(
|
||||
val formattedText: List<FormattedText>? = null
|
||||
|
||||
@@ -1925,6 +1925,20 @@
|
||||
<string name="button_welcome_message">Welcome message</string>
|
||||
<string name="group_link">Group link</string>
|
||||
<string name="channel_link">Channel link</string>
|
||||
<string name="channel_webpage">Channel webpage</string>
|
||||
<string name="group_webpage">Group webpage</string>
|
||||
<string name="advanced_options">Advanced options</string>
|
||||
<string name="web_page_url_placeholder">https://</string>
|
||||
<string name="allow_anyone_to_embed">Allow anyone to embed</string>
|
||||
<string name="enter_webpage_url">Enter webpage URL</string>
|
||||
<string name="webpage_url_footer">It will be shown to subscribers and used to allow loading the preview.</string>
|
||||
<string name="webpage_code">Webpage code</string>
|
||||
<string name="webpage_code_footer">Add this code to your webpage. It will display the preview of your channel / group.</string>
|
||||
<string name="copy_code">Copy code</string>
|
||||
<string name="webpage_info">Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting.</string>
|
||||
<string name="relays_no_web_support">Used chat relays do not support webpages.</string>
|
||||
<string name="embed_any_webpage_can_show">Any webpage can show the preview.</string>
|
||||
<string name="embed_only_your_page">Only your page above can show the preview.</string>
|
||||
<string name="create_group_link">Create group link</string>
|
||||
<string name="button_create_group_link">Create link</string>
|
||||
<string name="delete_link_question">Delete link?</string>
|
||||
|
||||
@@ -281,6 +281,7 @@ cliCommands =
|
||||
"SetGroupTimedMessages",
|
||||
"SetLocalDeviceName",
|
||||
"SetProfileAddress",
|
||||
"SetPublicGroupAccess",
|
||||
"SetSendReceipts",
|
||||
"SetShowMemberMessages",
|
||||
"SetShowMessages",
|
||||
|
||||
@@ -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:
|
||||
|
||||
+5
-2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <ID>','Menu of commands':[...]
|
||||
botCommandsP :: Parser [ChatBotCommand]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -261,6 +261,7 @@ mobileChatOpts dbOptions =
|
||||
tbqSize = 4096,
|
||||
deviceName = Nothing,
|
||||
chatRelay = False,
|
||||
webPreviewConfig = Nothing,
|
||||
highlyAvailable = False,
|
||||
yesToUpMigrations = False,
|
||||
migrationBackupPath = Just "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|]
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|]
|
||||
@@ -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 = ?
|
||||
|
||||
@@ -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=?)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}} =
|
||||
|
||||
@@ -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 "<base64url>.json" and leftover "<base64url>.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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+6
-1
@@ -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}}}
|
||||
|
||||
@@ -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 <name> 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
|
||||
|
||||
@@ -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\"}}}}}"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SimpleX Channel Preview</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
[data-simplex-channel-preview] { height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Optional attributes:
|
||||
data-color-scheme="light"
|
||||
data-dark-background="#000832"
|
||||
data-light-background="#ffffff"
|
||||
data-relay-scheme="https"
|
||||
-->
|
||||
<div data-simplex-channel-preview
|
||||
data-channel-link="YOUR_CHANNEL_LINK"
|
||||
data-channel-id="YOUR_CHANNEL_ID"
|
||||
data-relay-domains="relay1.example.com"
|
||||
data-app-download-buttons="on"
|
||||
></div>
|
||||
<script src="https://simplex.chat/js/channel-preview.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(/'/g, "'")
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
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 += `<strong>${escapeHtml(text)}</strong>`;
|
||||
break;
|
||||
case 'italic':
|
||||
html += `<em>${escapeHtml(text)}</em>`;
|
||||
break;
|
||||
case 'strikeThrough':
|
||||
html += `<s>${escapeHtml(text)}</s>`;
|
||||
break;
|
||||
case 'snippet':
|
||||
html += `<span style="font-family: monospace;">${escapeHtml(text)}</span>`;
|
||||
break;
|
||||
case 'secret':
|
||||
html += `<span class="secret">${escapeHtml(text)}</span>`;
|
||||
break;
|
||||
case 'small':
|
||||
html += `<span class="small-text">${escapeHtml(text)}</span>`;
|
||||
break;
|
||||
case 'colored':
|
||||
html += `<span class="${format.color}">${escapeHtml(text)}</span>`;
|
||||
break;
|
||||
case 'uri':
|
||||
let href = text.startsWith('http://') || text.startsWith('https://') || text.startsWith('simplex:/') ? text : 'https://' + text;
|
||||
html += `<a href="${href}"${targetBlank(href)}>${escapeHtml(text)}</a>`;
|
||||
break;
|
||||
case 'hyperLink': {
|
||||
const { showText, linkUri } = format;
|
||||
html += `<a href="${linkUri}"${targetBlank(linkUri)}>${escapeHtml(showText ?? linkUri)}</a>`;
|
||||
break;
|
||||
}
|
||||
case 'simplexLink': {
|
||||
const { showText, linkType, simplexUri, smpHosts } = format;
|
||||
const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType);
|
||||
html += `<a href="${platformSimplexUri(simplexUri)}" target="_blank">${linkText} <em>(${viaHost(smpHosts)})</em></a>`;
|
||||
break;
|
||||
}
|
||||
case 'command':
|
||||
html += `<span style="font-family: monospace;">${escapeHtml(text)}</span>`;
|
||||
break;
|
||||
case 'mention':
|
||||
html += `<strong>${escapeHtml(text)}</strong>`;
|
||||
break;
|
||||
case 'email':
|
||||
html += `<a href="mailto:${text}">${escapeHtml(text)}</a>`;
|
||||
break;
|
||||
case 'phone':
|
||||
html += `<a href="tel:${text}">${escapeHtml(text)}</a>`;
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -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(/'/g, "'")
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
function escapeAttr(text) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.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 += `<strong>${escapeHtml(text)}</strong>`;
|
||||
break;
|
||||
case 'italic':
|
||||
html += `<em>${escapeHtml(text)}</em>`;
|
||||
break;
|
||||
case 'strikeThrough':
|
||||
html += `<s>${escapeHtml(text)}</s>`;
|
||||
break;
|
||||
case 'snippet':
|
||||
html += `<span style="font-family: monospace;">${escapeHtml(text)}</span>`;
|
||||
break;
|
||||
case 'secret':
|
||||
html += `<span class="secret">${escapeHtml(text)}</span>`;
|
||||
break;
|
||||
case 'small':
|
||||
html += `<span class="small-text">${escapeHtml(text)}</span>`;
|
||||
break;
|
||||
case 'colored':
|
||||
html += `<span class="${(format.color || '').replace(/[^a-zA-Z0-9_-]/g, '')}">${escapeHtml(text)}</span>`;
|
||||
break;
|
||||
case 'uri': {
|
||||
let href = text.startsWith('http://') || text.startsWith('https://') || text.startsWith('simplex:/') ? text : 'https://' + text;
|
||||
html += `<a href="${safeHref(href)}"${targetBlank(href)}>${escapeHtml(text)}</a>`;
|
||||
break;
|
||||
}
|
||||
case 'hyperLink': {
|
||||
const { showText, linkUri } = format;
|
||||
html += `<a href="${safeHref(linkUri)}"${targetBlank(linkUri)}>${escapeHtml(showText ?? linkUri)}</a>`;
|
||||
break;
|
||||
}
|
||||
case 'simplexLink': {
|
||||
const { showText, linkType, simplexUri, smpHosts } = format;
|
||||
const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType);
|
||||
html += `<a href="${safeHref(platformSimplexUri(simplexUri))}" target="_blank">${linkText} <em>(${escapeHtml(viaHost(smpHosts))})</em></a>`;
|
||||
break;
|
||||
}
|
||||
case 'command':
|
||||
html += `<span style="font-family: monospace;">${escapeHtml(text)}</span>`;
|
||||
break;
|
||||
case 'mention':
|
||||
html += `<strong>${escapeHtml(text)}</strong>`;
|
||||
break;
|
||||
case 'email':
|
||||
html += `<a href="mailto:${escapeAttr(text)}">${escapeHtml(text)}</a>`;
|
||||
break;
|
||||
case 'phone':
|
||||
html += `<a href="tel:${escapeAttr(text)}">${escapeHtml(text)}</a>`;
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user