Merge branch 'master' into master-ios

This commit is contained in:
Evgeny Poberezkin
2023-05-15 13:28:27 +01:00
61 changed files with 2057 additions and 453 deletions
@@ -1355,12 +1355,12 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
val file = cItem.file
val mc = cItem.content.msgContent
if (file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) {
val acceptImages = appPrefs.privacyAcceptImages.get()
if ((mc is MsgContent.MCImage && acceptImages)
|| (mc is MsgContent.MCVoice && ((file.fileSize > MAX_VOICE_SIZE_FOR_SENDING && acceptImages) || cInfo is ChatInfo.Group))) {
withApi { receiveFile(r.user, file.fileId) } // TODO check inlineFileMode != IFMSent
}
if (file != null &&
appPrefs.privacyAcceptImages.get() &&
((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) {
withApi { receiveFile(r.user, file.fileId) }
}
if (cItem.showNotification && (!SimplexApp.context.isAppOnForeground || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
@@ -139,7 +139,6 @@ private fun FeatureSection(
ContactFeatureAllowed.values(userDefault).map { it to it.text },
allowFeature,
icon = null,
enabled = remember { mutableStateOf(feature != ChatFeature.Calls) },
onSelected = onSelected
)
InfoRow(
@@ -147,7 +146,7 @@ private fun FeatureSection(
pref.contactPreference.allow.text
)
}
SectionTextFooter(feature.enabledDescription(enabled) + (if (feature == ChatFeature.Calls) generalGetString(R.string.available_in_v51) else ""))
SectionTextFooter(feature.enabledDescription(enabled))
}
@Composable
@@ -307,7 +307,7 @@ private fun BoxScope.DeleteTextButton(composeState: MutableState<ComposeState>)
@Composable
private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNextClick: MutableState<Boolean>) {
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
val rec: Recorder = remember { RecorderNative() }
DisposableEffect(Unit) { onDispose { rec.stop() } }
val stopRecordingAndAddAudio: () -> Unit = {
recState.value.filePathNullable?.let {
@@ -36,6 +36,7 @@ fun CIVoiceView(
ci: ChatItem,
timedMessagesTTL: Int?,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
) {
Row(
Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = if (hasText) 6.dp else 0.dp, end = if (hasText) 6.dp else 0.dp),
@@ -64,11 +65,11 @@ fun CIVoiceView(
durationText(time / 1000)
}
}
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick) {
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) {
AudioPlayer.seekTo(it, progress, filePath)
}
} else {
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile)
val metaReserve = if (edited)
" "
else
@@ -93,8 +94,8 @@ private fun VoiceLayout(
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
onProgressChanged: (Int) -> Unit,
) {
@Composable
fun RowScope.Slider(backgroundColor: Color, padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
@@ -142,7 +143,7 @@ private fun VoiceLayout(
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
Spacer(Modifier.width(6.dp))
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile)
Row(verticalAlignment = Alignment.CenterVertically) {
DurationText(text, PaddingValues(start = 12.dp))
Slider(if (ci.chatDir.sent) sentColor else receivedColor)
@@ -156,7 +157,7 @@ private fun VoiceLayout(
DurationText(text, PaddingValues(end = 12.dp))
}
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
}
@@ -166,7 +167,7 @@ private fun VoiceLayout(
else -> {
Row {
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
}
@@ -245,7 +246,8 @@ private fun VoiceMsgIndicator(
error: Boolean,
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
) {
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
val strokeColor = MaterialTheme.colors.primary
@@ -264,8 +266,9 @@ private fun VoiceMsgIndicator(
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
}
} else {
if (file?.fileStatus is CIFileStatus.RcvInvitation
|| file?.fileStatus is CIFileStatus.RcvTransfer
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId) }, {}, longClick = longClick)
} else if (file?.fileStatus is CIFileStatus.RcvTransfer
|| file?.fileStatus is CIFileStatus.RcvAccepted
) {
Box(
@@ -203,7 +203,7 @@ fun ChatItemView(
EmojiItemView(cItem, cInfo.timedMessagesTTL)
MsgContentItemDropdownMenu()
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") })
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") }, receiveFile)
MsgContentItemDropdownMenu()
} else {
framedItemView()
@@ -221,7 +221,7 @@ fun FramedItemView(
}
}
is MsgContent.MCVoice -> {
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") })
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") }, receiveFile)
if (mc.text != "") {
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
}
@@ -21,7 +21,7 @@ interface Recorder {
fun stop(): Int
}
class RecorderNative(private val recordedBytesLimit: Long): Recorder {
class RecorderNative(): Recorder {
companion object {
// Allows to stop the recorder from outside without having the recorder in a variable
var stopRecording: (() -> Unit)? = null
@@ -48,9 +48,8 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
rec.setAudioChannels(1)
rec.setAudioSamplingRate(16000)
rec.setAudioEncodingBitRate(16000)
rec.setAudioEncodingBitRate(32000)
rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING)
rec.setMaxFileSize(recordedBytesLimit)
val tmpDir = SimplexApp.context.getDir("temp", Application.MODE_PRIVATE)
val fileToSave = File.createTempFile(generateNewFileName(SimplexApp.context, "voice", "${extension}_"), ".tmp", tmpDir)
fileToSave.deleteOnExit()
@@ -236,16 +236,16 @@ private fun spannableStringToAnnotatedString(
}
// maximum image file size to be auto-accepted
const val MAX_IMAGE_SIZE: Long = 236700
const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
const val MAX_VIDEO_SIZE_AUTO_RCV: Long = 1_047_552 // 1023KB
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 43_000
const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 300_000
const val MAX_FILE_SIZE_SMP: Long = 8000000
const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824
const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB
fun getFilesDirectory(context: Context): String {
return context.filesDir.toString()
@@ -99,11 +99,10 @@ private fun FeatureSection(feature: ChatFeature, allowFeature: State<FeatureAllo
FeatureAllowed.values().map { it to it.text },
allowFeature,
icon = feature.icon,
enabled = remember { mutableStateOf(feature != ChatFeature.Calls) },
onSelected = onSelected,
)
}
SectionTextFooter(feature.allowDescription(allowFeature.value) + (if (feature == ChatFeature.Calls) generalGetString(R.string.available_in_v51) else ""))
SectionTextFooter(feature.allowDescription(allowFeature.value))
}
@Composable
+18 -3
View File
@@ -36,8 +36,9 @@ class AudioRecorder {
try av.setActive(true)
let settings: [String : Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 12000,
AVEncoderBitRateKey: 12000,
AVSampleRateKey: 16000,
AVEncoderBitRateKey: 32000,
AVEncoderBitRateStrategyKey: AVAudioBitRateStrategy_VariableConstrained,
AVNumberOfChannelsKey: 1
]
let url = getAppFilePath(fileName)
@@ -102,11 +103,14 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
self.onFinishPlayback = onFinishPlayback
}
func start(fileName: String) {
func start(fileName: String, at: TimeInterval?) {
let url = getAppFilePath(fileName)
audioPlayer = try? AVAudioPlayer(contentsOf: url)
audioPlayer?.delegate = self
audioPlayer?.prepareToPlay()
if let at = at {
audioPlayer?.currentTime = at
}
audioPlayer?.play()
playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
@@ -125,6 +129,17 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
audioPlayer?.play()
}
func seek(_ to: TimeInterval) {
if audioPlayer?.isPlaying == true {
audioPlayer?.pause()
audioPlayer?.currentTime = to
audioPlayer?.play()
} else {
audioPlayer?.currentTime = to
}
self.onTimer?(to)
}
func stop() {
if let player = audioPlayer {
player.stop()
+5 -11
View File
@@ -301,9 +301,9 @@ func apiGetChatItemInfo(itemId: Int64) async throws -> ChatItemInfo {
throw r
}
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false) async -> ChatItem? {
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? {
let chatModel = ChatModel.shared
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live)
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
let r: ChatResponse
if type == .direct {
var cItem: ChatItem!
@@ -1240,15 +1240,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
m.increaseUnreadCounter(user: user)
}
if let file = cItem.file,
let mc = cItem.content.msgContent,
file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV {
let acceptImages = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
if (mc.isImage && acceptImages)
|| (mc.isVoice && ((file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND && acceptImages) || cInfo.chatType == .group)) {
Task {
await receiveFile(user: user, fileId: file.fileId) // TODO check inlineFileMode != IFMSent
}
if let file = cItem.autoReceiveFile() {
Task {
await receiveFile(user: user, fileId: file.fileId)
}
}
if cItem.showNotification {
@@ -32,7 +32,7 @@ func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparen
r = r + statusIconText("timer", color).font(.caption2)
let ttl = meta.itemTimed?.ttl
if ttl != chatTTL {
r = r + Text(TimedMessagesPreference.shortTtlText(ttl)).foregroundColor(color)
r = r + Text(shortTimeText(ttl)).foregroundColor(color)
}
r = r + Text(" ")
}
@@ -251,7 +251,6 @@ struct CIVideoView: View {
if let user = ChatModel.shared.currentUser {
await receiveFile(user, file.fileId)
}
// TODO image accepted alert?
}
}
@@ -13,14 +13,20 @@ struct CIVoiceView: View {
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval?
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
Group {
if chatItem.chatDir.sent {
VStack (alignment: .trailing, spacing: 6) {
HStack {
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
playerTime()
player()
}
@@ -32,13 +38,16 @@ struct CIVoiceView: View {
HStack {
player()
playerTime()
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
}
.frame(alignment: .leading)
metaView().padding(.leading, -6)
}
}
}
.padding([.top, .horizontal], 4)
.padding(.top, 4)
.padding(.bottom, 8)
}
@@ -48,8 +57,11 @@ struct CIVoiceView: View {
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
showBackground: true,
seek: $seek,
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime
playbackTime: $playbackTime,
allowMenu: $allowMenu
)
}
@@ -61,6 +73,22 @@ struct CIVoiceView: View {
)
.foregroundColor(.secondary)
}
private func playbackSlider() -> some View {
ComposeVoiceView.SliderBar(
length: TimeInterval(duration),
progress: $playbackTime,
seek: {
let time = max(0.0001, $0)
seek(time)
playbackTime = time
})
.onChange(of: .playing == playbackState || (playbackTime ?? 0) > 0) { show in
if !show {
allowMenu = true
}
}
}
private func metaView() -> some View {
CIMetaView(chatItem: chatItem)
@@ -95,10 +123,11 @@ struct VoiceMessagePlayer: View {
var recordingTime: TimeInterval
var showBackground: Bool
@State private var audioPlayer: AudioPlayer?
@Binding var seek: (TimeInterval) -> Void
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State private var startingPlayback: Bool = false
@Binding var allowMenu: Bool
var body: some View {
ZStack {
@@ -109,7 +138,7 @@ struct VoiceMessagePlayer: View {
case .sndComplete: playbackButton()
case .sndCancelled: playbackButton()
case .sndError: playbackButton()
case .rcvInvitation: loadingIcon()
case .rcvInvitation: downloadButton(recordingFile)
case .rcvAccepted: loadingIcon()
case .rcvTransfer: loadingIcon()
case .rcvComplete: playbackButton()
@@ -120,18 +149,24 @@ struct VoiceMessagePlayer: View {
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
}
}
.onDisappear {
audioPlayer?.stop()
.onAppear {
seek = { to in audioPlayer?.seek(to) }
audioPlayer?.onTimer = { playbackTime = $0 }
audioPlayer?.onFinishPlayback = {
playbackState = .noPlayback
playbackTime = TimeInterval(0)
}
}
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
if !startingPlayback {
.onChange(of: chatModel.stopPreviousRecPlay) { it in
if let recordingFileName = getLoadedFileName(recordingFile), chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
} else {
startingPlayback = false
}
}
.onChange(of: playbackState) { state in
allowMenu = state == .paused || state == .noPlayback
}
}
@ViewBuilder private func playbackButton() -> some View {
@@ -179,6 +214,18 @@ struct VoiceMessagePlayer: View {
}
}
private func downloadButton(_ recordingFile: CIFile) -> some View {
Button {
Task {
if let user = ChatModel.shared.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
} label: {
playPauseIcon("play.fill")
}
}
private struct ProgressCircle: View {
var length: TimeInterval
@Binding var progress: TimeInterval?
@@ -204,7 +251,6 @@ struct VoiceMessagePlayer: View {
}
private func startPlayback(_ recordingFileName: String) {
startingPlayback = true
chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName)
audioPlayer = AudioPlayer(
onTimer: { playbackTime = $0 },
@@ -213,8 +259,7 @@ struct VoiceMessagePlayer: View {
playbackTime = TimeInterval(0)
}
)
audioPlayer?.start(fileName: recordingFileName)
playbackTime = TimeInterval(0)
audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
playbackState = .playing
}
}
@@ -240,13 +285,15 @@ struct CIVoiceView_Previews: PreviewProvider {
chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30,
playbackState: .playing,
playbackTime: TimeInterval(20)
audioPlayer: .constant(nil),
playbackState: .constant(.playing),
playbackTime: .constant(TimeInterval(20)),
allowMenu: Binding.constant(true)
)
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
}
.previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
@@ -15,9 +15,15 @@ struct FramedCIVoiceView: View {
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval?
@Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
HStack {
VoiceMessagePlayer(
@@ -25,8 +31,11 @@ struct FramedCIVoiceView: View {
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
showBackground: false,
seek: $seek,
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime
playbackTime: $playbackTime,
allowMenu: $allowMenu
)
VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration),
@@ -35,12 +44,31 @@ struct FramedCIVoiceView: View {
)
.foregroundColor(.secondary)
.frame(width: 50, alignment: .leading)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
}
.padding(.top, 6)
.padding(.leading, 6)
.padding(.trailing, 12)
.padding(.bottom, chatItem.content.text.isEmpty ? 10 : 0)
}
private func playbackSlider() -> some View {
ComposeVoiceView.SliderBar(
length: TimeInterval(duration),
progress: $playbackTime,
seek: {
let time = max(0.0001, $0)
seek(time)
playbackTime = time
})
.onChange(of: .playing == playbackState || (playbackTime ?? 0) > 0) { show in
if !show {
allowMenu = true
}
}
}
}
struct FramedCIVoiceView_Previews: PreviewProvider {
File diff suppressed because one or more lines are too long
@@ -82,7 +82,7 @@ struct ChatItemInfoView: View {
.padding(.vertical, 6)
.background(ciDirFrameColor(chatItemSent: chatItemSent, colorScheme: colorScheme))
.cornerRadius(18)
.uiKitContextMenu(menu: uiMenu)
.uiKitContextMenu(menu: uiMenu, allowMenu: Binding.constant(true))
Text(
localTimestamp(itemVersion.itemVersionTs)
+ (current
+18 -2
View File
@@ -16,6 +16,22 @@ struct ChatItemView: View {
var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
@Binding var revealed: Bool
@Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding<Bool>, allowMenu: Binding<Bool> = .constant(false), audioPlayer: Binding<AudioPlayer?> = .constant(nil), playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback), playbackTime: Binding<TimeInterval?> = .constant(nil)) {
self.chatInfo = chatInfo
self.chatItem = chatItem
self.showMember = showMember
self.maxWidth = maxWidth
_scrollProxy = .init(initialValue: scrollProxy)
_revealed = revealed
_allowMenu = allowMenu
_audioPlayer = audioPlayer
_playbackState = playbackState
_playbackTime = playbackTime
}
var body: some View {
let ci = chatItem
@@ -25,7 +41,7 @@ struct ChatItemView: View {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration)
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil {
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
@@ -37,7 +53,7 @@ struct ChatItemView: View {
}
private func framedItemView() -> some View {
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
}
}
+57 -9
View File
@@ -219,17 +219,25 @@ struct ChatView: View {
.padding(.vertical, 8)
}
private func voiceWithoutFrame(_ ci: ChatItem) -> Bool {
ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil
}
private func chatItemsList() -> some View {
let cInfo = chat.chatInfo
return GeometryReader { g in
ScrollViewReader { proxy in
ScrollView {
let maxWidth =
cInfo.chatType == .group
? (g.size.width - 28) * 0.84 - 42
: (g.size.width - 32) * 0.84
LazyVStack(spacing: 5) {
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
let voiceNoFrame = voiceWithoutFrame(ci)
let maxWidth = cInfo.chatType == .group
? voiceNoFrame
? (g.size.width - 28) - 42
: (g.size.width - 28) * 0.84 - 42
: voiceNoFrame
? (g.size.width - 32)
: (g.size.width - 32) * 0.84
chatItemView(ci, maxWidth)
.scaleEffect(x: 1, y: -1, anchor: .center)
.onAppear {
@@ -435,6 +443,7 @@ struct ChatView: View {
private struct ChatItemWithMenu: View {
@EnvironmentObject var chat: Chat
@Environment(\.colorScheme) var colorScheme
var ci: ChatItem
var showMember: Bool = false
var maxWidth: CGFloat
@@ -448,15 +457,27 @@ struct ChatView: View {
@State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo?
@State private var allowMenu: Bool = true
@State private var audioPlayer: AudioPlayer?
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback
@State private var playbackTime: TimeInterval?
var body: some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
let uiMenu: Binding<UIMenu> = Binding(
get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) },
set: { _ in }
)
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed)
.uiKitContextMenu(menu: uiMenu)
VStack(alignment: .trailing, spacing: 4) {
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
if ci.reactions.count > 0 {
chatItemReactions(ci.reactions)
.padding(.bottom, 4)
}
}
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal)
@@ -469,13 +490,40 @@ struct ChatView: View {
}
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
.onDisappear {
if ci.content.msgContent?.isVoice == true {
allowMenu = true
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
}
}
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
chatItemInfo = nil
}) {
ChatItemInfoView(chatItemSent: ci.chatDir.sent, chatItemInfo: $chatItemInfo)
}
}
private func chatItemReactions(_ reactions: [CIReaction]) -> some View {
HStack(spacing: 4) {
ForEach(reactions, id: \.reaction) { r in
HStack(spacing: 4) {
switch r.reaction {
case let .emoji(emoji): Text(emoji).font(.caption)
}
if r.totalReacted > 1 {
Text("\(r.totalReacted)").font(.caption).foregroundColor(.secondary)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(!r.userReacted ? Color.clear : colorScheme == .dark ? sentColorDark : sentColorLight)
.cornerRadius(16)
}
}
}
private func menu(live: Bool) -> [UIAction] {
var menu: [UIAction] = []
if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed {
@@ -696,7 +744,7 @@ struct ChatView: View {
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
}
}
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
switch (prevItem?.chatDir) {
case .groupSnd: return true
@@ -277,8 +277,8 @@ struct ComposeView: View {
ZStack(alignment: .leading) {
SendMessageView(
composeState: $composeState,
sendMessage: {
sendMessage()
sendMessage: { ttl in
sendMessage(ttl: ttl)
resetLinkPreview()
},
sendLiveMessage: sendLiveMessage,
@@ -296,6 +296,9 @@ struct ComposeView: View {
},
finishVoiceMessageRecording: finishVoiceMessageRecording,
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
// TODO in 5.2 - allow if ttl is not configured
// timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages) && chat.chatInfo.timedMessagesTTL != nil,
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
keyboardVisible: $keyboardVisible
)
@@ -425,7 +428,7 @@ struct ComposeView: View {
&& (!composeState.message.isEmpty || composeState.liveMessage?.sentMsg != nil) {
cancelCurrentVoiceRecording()
clearCurrentDraft()
sendMessage()
sendMessage(ttl: nil)
resetLinkPreview()
} else if (composeState.inProgress) {
clearCurrentDraft()
@@ -470,7 +473,7 @@ struct ComposeView: View {
let lm = composeState.liveMessage
if (composeState.sendEnabled || composeState.quoting)
&& (lm == nil || lm?.sentMsg == nil),
let ci = await sendMessageAsync(typedMsg, live: true) {
let ci = await sendMessageAsync(typedMsg, live: true, ttl: nil) {
await MainActor.run {
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: typedMsg))
}
@@ -486,7 +489,7 @@ struct ComposeView: View {
let typedMsg = composeState.message
if let liveMessage = composeState.liveMessage {
if let sentMsg = liveMessageToSend(liveMessage, typedMsg),
let ci = await sendMessageAsync(sentMsg, live: true) {
let ci = await sendMessageAsync(sentMsg, live: true, ttl: nil) {
await MainActor.run {
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg))
}
@@ -578,15 +581,15 @@ struct ComposeView: View {
}
}
private func sendMessage() {
private func sendMessage(ttl: Int?) {
logger.debug("ChatView sendMessage")
Task {
logger.debug("ChatView sendMessage: in Task")
_ = await sendMessageAsync(nil, live: false)
_ = await sendMessageAsync(nil, live: false, ttl: ttl)
}
}
private func sendMessageAsync(_ text: String?, live: Bool) async -> ChatItem? {
private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?) async -> ChatItem? {
var sent: ChatItem?
let msgText = text ?? composeState.message
let liveMessage = composeState.liveMessage
@@ -606,36 +609,36 @@ struct ComposeView: View {
switch (composeState.preview) {
case .noPreview:
sent = await send(.text(msgText), quoted: quoted, live: live)
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live)
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
case let .mediaPreviews(mediaPreviews: media):
let last = media.count - 1
if last >= 0 {
for i in 0..<last {
if case (_, .video(_, _, _)) = media[i] {
sent = await sendVideo(media[i])
sent = await sendVideo(media[i], ttl: ttl)
} else {
sent = await sendImage(media[i])
sent = await sendImage(media[i], ttl: ttl)
}
_ = try? await Task.sleep(nanoseconds: 100_000000)
}
if case (_, .video(_, _, _)) = media[last] {
sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live)
sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
} else {
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live)
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
}
}
if sent == nil {
sent = await send(.text(msgText), quoted: quoted, live: live)
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
}
case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle()
chatModel.filesToDelete.remove(getAppFilePath(recordingFileName))
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName)
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName, ttl: ttl)
case let .filePreview(_, file):
if let savedFile = saveFileFromURL(file) {
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live)
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
}
}
}
@@ -691,30 +694,31 @@ struct ComposeView: View {
}
}
func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false) async -> ChatItem? {
func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
let (image, data) = imageData
if let data = data, let savedFile = saveAnyImage(data) {
return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live)
return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live, ttl: ttl)
}
return nil
}
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false) async -> ChatItem? {
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
let (image, data) = imageData
if case let .video(_, url, duration) = data, let savedFile = saveFileFromURLWithoutLoad(url) {
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live)
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
}
return nil
}
func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false) async -> ChatItem? {
func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
if let chatItem = await apiSendMessage(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
file: file,
quotedItemId: quoted,
msg: mc,
live: live
live: live,
ttl: ttl
) {
await MainActor.run {
chatModel.removeLiveDummy(animated: false)
@@ -38,7 +38,7 @@ struct ComposeVoiceView: View {
@State private var playbackTime: TimeInterval?
@State private var startingPlayback: Bool = false
private static let previewHeight: CGFloat = 50
private static let previewHeight: CGFloat = 55
var body: some View {
ZStack {
@@ -66,6 +66,7 @@ struct ComposeVoiceView: View {
}
}
.padding(.trailing, 12)
.padding(.top, 4)
ProgressBar(length: MAX_VOICE_MESSAGE_LENGTH, progress: $recordingTime)
}
@@ -105,9 +106,12 @@ struct ComposeVoiceView: View {
}
}
.padding(.trailing, 12)
.padding(.top, 4)
if let recordingLength = recordingTime {
ProgressBar(length: recordingLength, progress: $playbackTime)
GeometryReader { _ in
SliderBar(length: recordingLength, progress: $playbackTime, seek: { audioPlayer?.seek($0) })
}
}
}
.onChange(of: stopPlayback) { _ in
@@ -145,6 +149,18 @@ struct ComposeVoiceView: View {
}
}
struct SliderBar: View {
var length: TimeInterval
@Binding var progress: TimeInterval?
var seek: (TimeInterval) -> Void
var body: some View {
Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length)
.frame(maxWidth: .infinity)
.frame(height: 4)
}
}
private struct ProgressBar: View {
var length: TimeInterval
@Binding var progress: TimeInterval?
@@ -154,10 +170,10 @@ struct ComposeVoiceView: View {
ZStack {
Rectangle()
.fill(Color.accentColor)
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 3)
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
.animation(.linear, value: progress)
}
.frame(height: ComposeVoiceView.previewHeight - 1, alignment: .bottom) // minus 1 is for the bottom padding
.frame(height: 4)
}
}
}
@@ -172,8 +188,7 @@ struct ComposeVoiceView: View {
playbackTime = recordingTime // animate progress bar to the end
}
)
audioPlayer?.start(fileName: recordingFileName)
playbackTime = TimeInterval(0)
audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
playbackState = .playing
}
}
@@ -13,7 +13,7 @@ private let liveMsgInterval: UInt64 = 3000_000000
struct SendMessageView: View {
@Binding var composeState: ComposeState
var sendMessage: () -> Void
var sendMessage: (Int?) -> Void
var sendLiveMessage: (() async -> Void)? = nil
var updateLiveMessage: (() async -> Void)? = nil
var cancelLiveMessage: (() -> Void)? = nil
@@ -23,6 +23,7 @@ struct SendMessageView: View {
var startVoiceMessageRecording: (() -> Void)? = nil
var finishVoiceMessageRecording: (() -> Void)? = nil
var allowVoiceMessagesToContact: (() -> Void)? = nil
var timedMessageAllowed: Bool = false
var onMediaAdded: ([UploadContent]) -> Void
@State private var holdingVMR = false
@Namespace var namespace
@@ -32,6 +33,9 @@ struct SendMessageView: View {
@State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body)
@State private var sendButtonSize: CGFloat = 29
@State private var sendButtonOpacity: CGFloat = 1
@State private var showCustomDisappearingMessageDialogue = false
@State private var showCustomTimePicker = false
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
var maxHeight: CGFloat = 360
var minHeight: CGFloat = 37
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
@@ -147,15 +151,17 @@ struct SendMessageView: View {
.padding([.top, .trailing], 4)
}
@ViewBuilder private func sendMessageButton() -> some View {
let v = Button(action: sendMessage) {
private func sendMessageButton() -> some View {
Button {
sendMessage(nil)
} label: {
Image(systemName: composeState.editing || composeState.liveMessage != nil
? "checkmark.circle.fill"
: "arrow.up.circle.fill")
.resizable()
.foregroundColor(.accentColor)
.frame(width: sendButtonSize, height: sendButtonSize)
.opacity(sendButtonOpacity)
? "checkmark.circle.fill"
: "arrow.up.circle.fill")
.resizable()
.foregroundColor(.accentColor)
.frame(width: sendButtonSize, height: sendButtonSize)
.opacity(sendButtonOpacity)
}
.disabled(
!composeState.sendEnabled ||
@@ -164,22 +170,61 @@ struct SendMessageView: View {
composeState.endLiveDisabled
)
.frame(width: 29, height: 29)
.contextMenu{
sendButtonContextMenuItems()
}
.padding([.bottom, .trailing], 4)
.confirmationDialog("Send disappearing message", isPresented: $showCustomDisappearingMessageDialogue, titleVisibility: .visible) {
Button("30 seconds") { sendMessage(30) }
Button("1 minute") { sendMessage(60) }
Button("5 minutes") { sendMessage(300) }
Button("Custom time") { showCustomTimePicker = true }
}
.sheet(isPresented: $showCustomTimePicker, onDismiss: { selectedDisappearingMessageTime = customDisappearingMessageTimeDefault.get() }) {
if #available(iOS 16.0, *) {
disappearingMessageCustomTimePicker()
.presentationDetents([.fraction(0.6)])
} else {
disappearingMessageCustomTimePicker()
}
}
}
private func disappearingMessageCustomTimePicker() -> some View {
CustomTimePickerView(
selection: $selectedDisappearingMessageTime,
confirmButtonText: "Send",
confirmButtonAction: {
if let time = selectedDisappearingMessageTime {
sendMessage(time)
customDisappearingMessageTimeDefault.set(time)
}
},
description: "Delete after"
)
}
@ViewBuilder private func sendButtonContextMenuItems() -> some View {
if composeState.liveMessage == nil,
case .noContextItem = composeState.contextItem,
!composeState.voicePreview && !composeState.editing,
let send = sendLiveMessage,
let update = updateLiveMessage {
v.contextMenu{
!composeState.editing {
if case .noContextItem = composeState.contextItem,
!composeState.voicePreview,
let send = sendLiveMessage,
let update = updateLiveMessage {
Button {
startLiveMessage(send: send, update: update)
} label: {
Label("Send live message", systemImage: "bolt.fill")
}
}
.padding([.bottom, .trailing], 4)
} else {
v.padding([.bottom, .trailing], 4)
if timedMessageAllowed {
Button {
hideKeyboard()
showCustomDisappearingMessageDialogue = true
} label: {
Label("Disappearing message", systemImage: "stopwatch")
}
}
}
}
@@ -365,7 +410,7 @@ struct SendMessageView_Previews: PreviewProvider {
Spacer(minLength: 0)
SendMessageView(
composeState: $composeStateNew,
sendMessage: {},
sendMessage: { _ in },
onMediaAdded: { _ in },
keyboardVisible: $keyboardVisible
)
@@ -375,7 +420,7 @@ struct SendMessageView_Previews: PreviewProvider {
Spacer(minLength: 0)
SendMessageView(
composeState: $composeStateEditing,
sendMessage: {},
sendMessage: { _ in },
onMediaAdded: { _ in },
keyboardVisible: $keyboardVisible
)
@@ -25,7 +25,7 @@ struct ContactPreferencesView: View {
timedMessagesFeatureSection()
featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice)
featureSection(.calls, user.fullPreferences.calls.allow, contact.mergedPreferences.calls, $featuresAllowed.calls).disabled(true)
featureSection(.calls, user.fullPreferences.calls.allow, contact.mergedPreferences.calls, $featuresAllowed.calls)
Section {
Button("Reset") { featuresAllowed = currentFeaturesAllowed }
@@ -89,9 +89,16 @@ struct ContactPreferencesView: View {
}
infoRow("Contact allows", pref.contactPreference.allow.text)
if featuresAllowed.timedMessagesAllowed {
timedMessagesTTLPicker($featuresAllowed.timedMessagesTTL)
DropdownCustomTimePicker(
selection: $featuresAllowed.timedMessagesTTL,
label: "Delete after",
dropdownValues: TimedMessagesPreference.ttlValues,
customPickerConfirmButtonText: "Select",
customPickerDescription: "Delete after"
)
.frame(height: 36)
} else if pref.contactPreference.allow == .yes || pref.contactPreference.allow == .always {
infoRow("Delete after", TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
infoRow("Delete after", timeText(pref.contactPreference.ttl))
}
}
header: { featureHeader(.timedMessages, enabled) }
@@ -107,7 +114,7 @@ struct ContactPreferencesView: View {
}
private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
(Text(feature.enabledDescription(enabled)) + (feature == .calls ? Text("\nAvailable in v5.1").bold() : Text("")))
Text(feature.enabledDescription(enabled))
.frame(height: 36, alignment: .topLeading)
}
@@ -129,18 +136,6 @@ struct ContactPreferencesView: View {
}
}
func timedMessagesTTLPicker(_ selection: Binding<Int?>) -> some View {
Picker("Delete after", selection: selection) {
let selectedTTL = selection.wrappedValue
let ttlValues = TimedMessagesPreference.ttlValues
let values = ttlValues + (ttlValues.contains(selectedTTL) ? [] : [selectedTTL])
ForEach(values, id: \.self) { ttl in
Text(TimedMessagesPreference.ttlText(ttl))
}
}
.frame(height: 36)
}
struct ContactPreferencesView_Previews: PreviewProvider {
static var previews: some View {
ContactPreferencesView(
@@ -75,14 +75,21 @@ struct GroupPreferencesView: View {
Toggle(feature.text, isOn: enable)
}
if timedOn {
timedMessagesTTLPicker($preferences.timedMessages.ttl)
DropdownCustomTimePicker(
selection: $preferences.timedMessages.ttl,
label: "Delete after",
dropdownValues: TimedMessagesPreference.ttlValues,
customPickerConfirmButtonText: "Select",
customPickerDescription: "Delete after"
)
.frame(height: 36)
}
} else {
settingsRow(icon, color: color) {
infoRow(Text(feature.text), enableFeature.wrappedValue.text)
}
if timedOn {
infoRow("Delete after", TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
infoRow("Delete after", timeText(preferences.timedMessages.ttl))
}
}
} footer: {
@@ -11,11 +11,12 @@ import UIKit
import SwiftUI
extension View {
func uiKitContextMenu(menu: Binding<UIMenu>) -> some View {
self.overlay(Color(uiColor: .systemBackground))
.overlay(
InteractionView(content: self, menu: menu)
)
func uiKitContextMenu(menu: Binding<UIMenu>, allowMenu: Binding<Bool>) -> some View {
self.overlay {
if allowMenu.wrappedValue {
self.overlay(Color(uiColor: .systemBackground)).overlay(InteractionView(content: self, menu: menu))
}
}
}
}
@@ -0,0 +1,223 @@
//
// CustomTimePicker.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 11.05.2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct CustomTimePicker: View {
@Binding var selection: Int?
@State var timeUnitsLimits = TimeUnitLimits.defaultUnitsLimits
@State private var selectedUnit: CustomTimeUnit = .second
@State private var selectedDuration: Int = 1
struct TimeUnitLimits {
var timeUnit: CustomTimeUnit
var minValue: Int = 1
var maxValue: Int
public static func defaultUnitLimits(_ unit: CustomTimeUnit) -> TimeUnitLimits {
switch unit {
case .second: return TimeUnitLimits.init(timeUnit: .second, maxValue: 120)
case .minute: return TimeUnitLimits.init(timeUnit: .minute, maxValue: 120)
case .hour: return TimeUnitLimits.init(timeUnit: .hour, maxValue: 72)
case .day: return TimeUnitLimits.init(timeUnit: .day, maxValue: 30)
case .week: return TimeUnitLimits.init(timeUnit: .week, maxValue: 12)
case .month: return TimeUnitLimits.init(timeUnit: .month, maxValue: 3)
}
}
public static var defaultUnitsLimits: [TimeUnitLimits] {[
defaultUnitLimits(.second),
defaultUnitLimits(.minute),
defaultUnitLimits(.hour),
defaultUnitLimits(.day),
defaultUnitLimits(.week),
defaultUnitLimits(.month),
]}
}
var body: some View {
HStack(spacing: 0) {
Group {
Picker("Duration", selection: $selectedDuration) {
let selectedUnitLimits = timeUnitsLimits.first(where: { $0.timeUnit == selectedUnit }) ?? TimeUnitLimits.defaultUnitLimits(selectedUnit)
let selectedUnitValues = Array(selectedUnitLimits.minValue...selectedUnitLimits.maxValue)
let values = selectedUnitValues + (selectedUnitValues.contains(selectedDuration) ? [] : [selectedDuration])
ForEach(values, id: \.self) { value in
Text("\(value)")
}
}
Picker("Unit", selection: $selectedUnit) {
ForEach(timeUnitsLimits.map { $0.timeUnit }, id: \.self) { timeUnit in
Text(timeUnit.text)
}
}
}
.pickerStyle(.wheel)
.frame(minWidth: 0)
.compositingGroup()
.clipped()
}
.onAppear {
if let selection = selection,
selection > 0 {
(selectedUnit, selectedDuration) = CustomTimeUnit.toTimeUnit(seconds: selection)
} else {
selection = selectedUnit.toSeconds * selectedDuration
}
}
.onChange(of: selectedUnit) { unit in
if let maxValue = timeUnitsLimits.first(where: { $0.timeUnit == unit })?.maxValue,
selectedDuration > maxValue {
selectedDuration = maxValue
} else {
selection = unit.toSeconds * selectedDuration
}
}
.onChange(of: selectedDuration) { duration in
selection = selectedUnit.toSeconds * duration
}
}
}
extension UIPickerView {
open override var intrinsicContentSize: CGSize {
return CGSize(width: UIView.noIntrinsicMetric, height: super.intrinsicContentSize.height)
}
}
struct CustomTimePickerView: View {
@Environment(\.dismiss) var dismiss
@Binding var selection: Int?
var confirmButtonText: LocalizedStringKey
var confirmButtonAction: () -> Void
var description: LocalizedStringKey? = nil
var timeUnitsLimits = CustomTimePicker.TimeUnitLimits.defaultUnitsLimits
var body: some View {
NavigationView {
customTimePickerView()
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
confirmButtonAction()
dismiss()
} label: {
Text(confirmButtonText)
.fontWeight(.medium)
}
.disabled(selection == nil)
}
}
}
}
private func customTimePickerView() -> some View {
VStack(alignment: .leading) {
List {
Group {
Section(description ?? "") {
CustomTimePicker(selection: $selection)
}
}
.listRowInsets(.init(top: 0, leading: 16, bottom: 0, trailing: 16))
}
.listStyle(.insetGrouped)
}
}
}
struct DropdownCustomTimePicker: View {
@Binding var selection: Int?
var label: LocalizedStringKey
var dropdownValues: [Int?]
var customPickerConfirmButtonText: LocalizedStringKey
var customPickerDescription: LocalizedStringKey? = nil
var customPickerTimeUnitsLimits = CustomTimePicker.TimeUnitLimits.defaultUnitsLimits
@State private var dropdownSelection: DropdownSelection = .dropdownValue(value: nil)
@State private var showCustomTimePicker = false
@State private var selectedCustomTime: Int? = nil
enum DropdownSelection: Hashable {
case dropdownValue(value: Int?)
case custom
}
var body: some View {
Picker(label, selection: $dropdownSelection) {
let values: [DropdownSelection] =
dropdownValues.map { .dropdownValue(value: $0) }
+ (dropdownValues.contains(selection) ? [] : [.dropdownValue(value: selection)])
+ [.custom]
ForEach(values, id: \.self) { v in
switch v {
case let .dropdownValue(value): Text(timeText(value))
case .custom: Text(NSLocalizedString("custom", comment: "dropdown time picker choice"))
}
}
}
.onAppear {
dropdownSelection = .dropdownValue(value: selection)
}
.onChange(of: selection) { v in
logger.debug("*** .onChange(of: selection)")
dropdownSelection = .dropdownValue(value: v)
}
.onChange(of: dropdownSelection) { v in
logger.debug("*** .onChange(of: dropdownSelection)")
switch v {
case let .dropdownValue(value): selection = value
case .custom: showCustomTimePicker = true
}
}
.sheet(
isPresented: $showCustomTimePicker,
onDismiss: {
dropdownSelection = .dropdownValue(value: selection)
selectedCustomTime = nil
}
) {
if #available(iOS 16.0, *) {
customTimePicker()
.presentationDetents([.fraction(0.6)])
} else {
customTimePicker()
}
}
}
private func customTimePicker() -> some View {
CustomTimePickerView(
selection: $selectedCustomTime,
confirmButtonText: customPickerConfirmButtonText,
confirmButtonAction: {
if let time = selectedCustomTime {
selection = time
}
},
description: customPickerDescription,
timeUnitsLimits: customPickerTimeUnitsLimits
)
.onAppear {
selectedCustomTime = selection
}
}
}
struct CustomTimePicker_Previews: PreviewProvider {
static var previews: some View {
CustomTimePicker(
selection: Binding.constant(300)
)
}
}
+2 -2
View File
@@ -72,7 +72,7 @@ struct TerminalView: View {
SendMessageView(
composeState: $composeState,
sendMessage: sendMessage,
sendMessage: { _ in consoleSendMessage() },
showVoiceMessageButton: false,
onMediaAdded: { _ in },
keyboardVisible: $keyboardVisible
@@ -108,7 +108,7 @@ struct TerminalView: View {
.onDisappear { terminalItem = nil }
}
func sendMessage() {
func consoleSendMessage() {
let cmd = ChatCommand.string(composeState.message)
if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) {
let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty")))
@@ -21,7 +21,7 @@ struct PreferencesView: View {
timedMessagesFeatureSection($preferences.timedMessages.allow)
featureSection(.fullDelete, $preferences.fullDelete.allow)
featureSection(.voice, $preferences.voice.allow)
featureSection(.calls, $preferences.calls.allow).disabled(true)
featureSection(.calls, $preferences.calls.allow)
Section {
Button("Reset") { preferences = currentPreferences }
@@ -61,7 +61,7 @@ struct PreferencesView: View {
}
private func featureFooter(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
(Text(feature.allowDescription(allowFeature.wrappedValue)) + (feature == .calls ? Text("\nAvailable in v5.1").bold() : Text("")))
Text(feature.allowDescription(allowFeature.wrappedValue))
.frame(height: 36, alignment: .topLeading)
}
@@ -48,6 +48,7 @@ let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert"
let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion"
let DEFAULT_ONBOARDING_STAGE = "onboardingStage"
let DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime"
let appDefaults: [String: Any] = [
DEFAULT_SHOW_LA_NOTICE: false,
@@ -76,6 +77,7 @@ let appDefaults: [String: Any] = [
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true,
DEFAULT_SHOW_MUTE_PROFILE_ALERT: true,
DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue,
DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300,
]
enum SimpleXLinkMode: String, Identifiable {
@@ -112,6 +114,8 @@ let privacyLocalAuthModeDefault = EnumDefault<LAMode>(defaults: UserDefaults.sta
let onboardingStageDefault = EnumDefault<OnboardingStage>(defaults: UserDefaults.standard, forKey: DEFAULT_ONBOARDING_STAGE, withDefault: .onboardingComplete)
let customDisappearingMessageTimeDefault = IntDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME)
func setGroupDefaults() {
privacyAcceptImagesGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES))
}
+2 -19
View File
@@ -271,25 +271,8 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
if !cInfo.ntfsEnabled {
ntfBadgeCountGroupDefault.set(max(0, ntfBadgeCountGroupDefault.get() - 1))
}
if case .image = cItem.content.msgContent {
if let file = cItem.file,
file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV,
privacyAcceptImagesGroupDefault.get() {
cItem = autoReceiveFile(file) ?? cItem
}
} else if case .video = cItem.content.msgContent {
if let file = cItem.file,
file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV,
privacyAcceptImagesGroupDefault.get() {
cItem = autoReceiveFile(file) ?? cItem
}
} else if case .voice = cItem.content.msgContent { // TODO check inlineFileMode != IFMSent
if let file = cItem.file,
file.fileSize <= MAX_IMAGE_SIZE,
file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND,
privacyAcceptImagesGroupDefault.get() {
cItem = autoReceiveFile(file) ?? cItem
}
if let file = cItem.autoReceiveFile() {
cItem = autoReceiveFile(file) ?? cItem
}
let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(notification: createMessageReceivedNtf(user, cInfo, cItem)) : .empty
return cItem.showMutableNotification ? (aChatItem.chatId, ntf) : nil
@@ -173,6 +173,7 @@
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; };
@@ -444,6 +445,7 @@
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = "<group>"; };
@@ -612,6 +614,7 @@
5CCB939B297EFCB100399E78 /* NavStackCompat.swift */,
18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */,
64466DCB29FFE3E800E3D48D /* MailView.swift */,
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -1190,6 +1193,7 @@
1841538E296606C74533367C /* UserPicker.swift in Sources */,
18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */,
1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */,
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */,
18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */,
184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */,
5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */,
+4 -3
View File
@@ -37,7 +37,7 @@ public enum ChatCommand {
case apiGetChats(userId: Int64)
case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String)
case apiGetChatItemInfo(itemId: Int64)
case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool)
case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool, ttl: Int?)
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool)
case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode)
case apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64)
@@ -141,9 +141,10 @@ public enum ChatCommand {
case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" +
(search == "" ? "" : " search=\(search)")
case let .apiGetChatItemInfo(itemId): return "/_get item info \(itemId)"
case let .apiSendMessage(type, id, file, quotedItemId, mc, live):
case let .apiSendMessage(type, id, file, quotedItemId, mc, live, ttl):
let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc))
return "/_send \(ref(type, id)) live=\(onOff(live)) json \(msg)"
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msg)"
case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)"
case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)"
case let .apiDeleteMemberChatItem(groupId, groupMemberId, itemId): return "/_delete member item #\(groupId) \(groupMemberId) \(itemId)"
+138 -47
View File
@@ -280,60 +280,107 @@ public struct TimedMessagesPreference: Preference {
}
public static var ttlValues: [Int?] {
[30, 300, 3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400, nil]
[3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400, nil]
}
}
public enum CustomTimeUnit {
case second
case minute
case hour
case day
case week
case month
public var toSeconds: Int {
switch self {
case .second: return 1
case .minute: return 60
case .hour: return 3600
case .day: return 86400
case .week: return 7 * 86400
case .month: return 30 * 86400
}
}
public static func ttlText(_ ttl: Int?) -> String {
guard let ttl = ttl else { return "off" }
if ttl == 0 { return "0 sec" }
let (m_, s) = divMod(ttl, by: 60)
let (h_, m) = divMod(m_, by: 60)
let (d_, h) = divMod(h_, by: 24)
let (mm, d) = divMod(d_, by: 30)
return maybe(mm,
mm == 1
? NSLocalizedString("1 month", comment: "message ttl")
: String.localizedStringWithFormat(NSLocalizedString("%d months", comment: "message ttl"), mm)
)
+ maybe(d,
d == 1
? NSLocalizedString("1 day", comment: "message ttl")
: d == 7
? NSLocalizedString("1 week", comment: "message ttl")
: d == 14
? NSLocalizedString("2 weeks", comment: "message ttl")
: String.localizedStringWithFormat(NSLocalizedString("%d days", comment: "message ttl"), d)
)
+ maybe(h,
h == 1
? NSLocalizedString("1 hour", comment: "message ttl")
: String.localizedStringWithFormat(NSLocalizedString("%d hours", comment: "message ttl"), h)
)
+ maybe(m, String.localizedStringWithFormat(NSLocalizedString("%d min", comment: "message ttl"), m))
+ maybe(s, String.localizedStringWithFormat(NSLocalizedString("%d sec", comment: "message ttl"), s))
public var text: String {
switch self {
case .second: return NSLocalizedString("seconds", comment: "time unit")
case .minute: return NSLocalizedString("minutes", comment: "time unit")
case .hour: return NSLocalizedString("hours", comment: "time unit")
case .day: return NSLocalizedString("days", comment: "time unit")
case .week: return NSLocalizedString("weeks", comment: "time unit")
case .month: return NSLocalizedString("months", comment: "time unit")
}
}
public static func shortTtlText(_ ttl: Int?) -> LocalizedStringKey {
guard let ttl = ttl else { return "off" }
let m = ttl / 60
if m == 0 { return "\(ttl)s" }
let h = m / 60
if h == 0 { return "\(m)m" }
let d = h / 24
if d == 0 { return "\(h)h" }
let mm = d / 30
if mm > 0 { return "\(mm)mth" }
let w = d / 7
return w == 0 || d % 7 != 0 ? "\(d)d" : "\(w)w"
public static func toTimeUnit(seconds: Int) -> (CustomTimeUnit, Int) {
let tryUnits = [month, week, day, hour, minute]
var selectedUnit: (CustomTimeUnit, Int)? = nil
for unit in tryUnits {
let (v, r) = divMod(seconds, by: unit.toSeconds)
if r == 0 {
selectedUnit = (unit, v)
break
}
}
return selectedUnit ?? (CustomTimeUnit.second, seconds)
}
static func divMod(_ n: Int, by d: Int) -> (Int, Int) {
private static func divMod(_ n: Int, by d: Int) -> (Int, Int) {
(n / d, n % d)
}
static func maybe(_ n: Int, _ s: String) -> String {
n == 0 ? "" : s
public static func toText(seconds: Int) -> String {
let (unit, value) = toTimeUnit(seconds: seconds)
switch unit {
case .second:
return String.localizedStringWithFormat(NSLocalizedString("%d sec", comment: "time interval"), value)
case .minute:
return String.localizedStringWithFormat(NSLocalizedString("%d min", comment: "time interval"), value)
case .hour:
return value == 1
? NSLocalizedString("1 hour", comment: "time interval")
: String.localizedStringWithFormat(NSLocalizedString("%d hours", comment: "time interval"), value)
case .day:
return value == 1
? NSLocalizedString("1 day", comment: "time interval")
: String.localizedStringWithFormat(NSLocalizedString("%d days", comment: "time interval"), value)
case .week:
return value == 1
? NSLocalizedString("1 week", comment: "time interval")
: String.localizedStringWithFormat(NSLocalizedString("%d weeks", comment: "time interval"), value)
case .month:
return value == 1
? NSLocalizedString("1 month", comment: "time interval")
: String.localizedStringWithFormat(NSLocalizedString("%d months", comment: "time interval"), value)
}
}
public static func toShortText(seconds: Int) -> LocalizedStringKey {
let (unit, value) = toTimeUnit(seconds: seconds)
switch unit {
case .second: return "\(value)s"
case .minute: return "\(value)m"
case .hour: return "\(value)h"
case .day: return "\(value)d"
case .week: return "\(value)w"
case .month: return "\(value)mth"
}
}
}
public func timeText(_ seconds: Int?) -> String {
guard let seconds = seconds else { return "off" }
if seconds == 0 { return "0 sec" }
return CustomTimeUnit.toText(seconds: seconds)
}
public func shortTimeText(_ seconds: Int?) -> LocalizedStringKey {
guard let seconds = seconds else { return "off" }
if seconds == 0 { return "0s" }
return CustomTimeUnit.toShortText(seconds: seconds)
}
public struct ContactUserPreferences: Decodable {
@@ -1762,6 +1809,17 @@ public struct ChatItem: Identifiable, Decodable {
self.content = content
self.formattedText = formattedText
self.quotedItem = quotedItem
self.reactions = [] // [
// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: false, totalReacted: 1),
// CIReaction(reaction: .emoji(emoji: ""), userReacted: false, totalReacted: 1),
// CIReaction(reaction: .emoji(emoji: "🚀"), userReacted: false, totalReacted: 3),
// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "👎"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "👀"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "🎉"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "😀"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "😕"), userReacted: true, totalReacted: 2),
// ]
self.file = file
}
@@ -1770,6 +1828,17 @@ public struct ChatItem: Identifiable, Decodable {
public var content: CIContent
public var formattedText: [FormattedText]?
public var quotedItem: CIQuote?
public var reactions: [CIReaction] = [] // [
// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: false, totalReacted: 1),
// CIReaction(reaction: .emoji(emoji: ""), userReacted: false, totalReacted: 1),
// CIReaction(reaction: .emoji(emoji: "🚀"), userReacted: false, totalReacted: 3),
// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "👎"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "👀"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "🎉"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "😀"), userReacted: true, totalReacted: 2),
// CIReaction(reaction: .emoji(emoji: "😕"), userReacted: true, totalReacted: 2),
// ]
public var file: CIFile?
public var viewTimestamp = Date.now
@@ -1851,6 +1920,18 @@ public struct ChatItem: Identifiable, Decodable {
}
}
public func autoReceiveFile() -> CIFile? {
if let file = file,
let mc = content.msgContent,
privacyAcceptImagesGroupDefault.get(),
(mc.isImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV)
|| (mc.isVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV)
|| (mc.isVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus != .rcvAccepted) {
return file
}
return nil
}
public var showMutableNotification: Bool {
switch content {
case .rcvCall: return false
@@ -2215,13 +2296,13 @@ public enum CIContent: Decodable, ItemContent {
static func featureText(_ feature: Feature, _ enabled: String, _ param: Int?) -> String {
feature.hasParam
? "\(feature.text): \(TimedMessagesPreference.ttlText(param))"
? "\(feature.text): \(timeText(param))"
: "\(feature.text): \(enabled)"
}
public static func preferenceText(_ feature: Feature, _ allowed: FeatureAllowed, _ param: Int?) -> String {
allowed != .no && feature.hasParam && param != nil
? String.localizedStringWithFormat(NSLocalizedString("offered %@: %@", comment: "feature offered item"), feature.text, TimedMessagesPreference.ttlText(param))
? String.localizedStringWithFormat(NSLocalizedString("offered %@: %@", comment: "feature offered item"), feature.text, timeText(param))
: allowed != .no
? String.localizedStringWithFormat(NSLocalizedString("offered %@", comment: "feature offered item"), feature.text)
: String.localizedStringWithFormat(NSLocalizedString("cancelled %@", comment: "feature offered item"), feature.text)
@@ -2286,6 +2367,16 @@ public struct CIQuote: Decodable, ItemContent {
}
}
public struct CIReaction: Decodable {
public var reaction: MsgReaction
public var userReacted: Bool
public var totalReacted: Int
}
public enum MsgReaction: Decodable, Hashable {
case emoji(emoji: String)
}
public struct CIFile: Decodable {
public var fileId: Int64
public var fileName: String
@@ -2383,7 +2474,7 @@ public enum FileProtocol: String, Decodable {
case xftp = "xftp"
}
public enum CIFileStatus: Decodable {
public enum CIFileStatus: Decodable, Equatable {
case sndStored
case sndTransfer(sndProgress: Int64, sndTotal: Int64)
case sndComplete
+7 -7
View File
@@ -11,20 +11,20 @@ import OSLog
let logger = Logger()
// maximum image file size to be auto-accepted
public let MAX_IMAGE_SIZE: Int64 = 236700
// image file size for complession
public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255KB
public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2
public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 8000000
public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2
public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824
public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023KB
public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1GB
public let MAX_FILE_SIZE_SMP: Int64 = 8000000
public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(30)
public let MAX_VOICE_MESSAGE_SIZE_INLINE_SEND: Int64 = 94680
public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(300)
private let CHAT_DB: String = "_chat.db"
+1
View File
@@ -104,6 +104,7 @@ mkChatOpts BroadcastBotOpts {coreOptions} =
chatCmdDelay = 3,
chatServerPort = Nothing,
optFilesFolder = Nothing,
showReactions = False,
allowInstantFiles = True,
muteNotifications = True,
maintenance = False
@@ -17,7 +17,7 @@ async function run() {
const address = (await chat.apiGetUserAddress()) || (await chat.apiCreateUserAddress())
console.log(`Bot address: ${address}`)
// enables automatic acceptance of contact connections
await chat.addressAutoAccept(true)
await chat.enableAddressAutoAccept()
await processMessages(chat)
async function processMessages(chat) {
@@ -40,14 +40,11 @@ async function run() {
const {chatInfo} = resp.chatItem
if (chatInfo.type !== ChatInfoType.Direct) continue
const msg = ciContentText(resp.chatItem.chatItem.content)
let reply
if (msg) {
const n = +msg
reply = typeof n === "number" ? `${n} * ${n} = ${n * n}` : `${n} is not a number`
} else {
reply = "no message text"
const reply = typeof n === "number" && !isNaN(n) ? `${n} * ${n} = ${n * n}` : `this is not a number`
await chat.apiSendTextMessage(ChatType.Direct, chatInfo.contact.contactId, reply)
}
await chat.apiSendTextMessage(ChatType.Direct, chatInfo.contact.contactId, reply)
}
}
}
@@ -1,6 +1,6 @@
{
"name": "simplex-chat",
"version": "0.1.1",
"version": "0.2.0",
"description": "SimpleX Chat client",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -36,7 +36,7 @@
},
"devDependencies": {
"@types/jest": "^27.5.1",
"@types/node": "^17.0.24",
"@types/node": "^18.11.18",
"@typescript-eslint/eslint-plugin": "^5.23.0",
"@typescript-eslint/parser": "^5.23.0",
"eslint": "^8.15.0",
@@ -48,7 +48,7 @@
"rollup": "^2.72.1",
"ts-jest": "^28.0.2",
"ts-node": "^10.7.0",
"typescript": "^4.6.3"
"typescript": "^4.9.3"
},
"husky": {
"hooks": {
@@ -105,8 +105,8 @@ export class ChatClient {
}
}
async apiCreateActiveUser(profile: CC.Profile): Promise<CR.User> {
const r = await this.sendChatCommand({type: "createActiveUser", profile})
async apiCreateActiveUser(profile?: Profile, sameServers = true, pastTimestamp = false): Promise<CR.User> {
const r = await this.sendChatCommand({type: "createActiveUser", profile, sameServers, pastTimestamp})
if (r.type === "activeUser") return r.user
throw new ChatCommandError("unexpected response", r)
}
@@ -129,15 +129,22 @@ export class ChatClient {
return this.okChatCommand({type: "setIncognito", incognito})
}
async addressAutoAccept(autoAccept: boolean, autoReply: CC.MsgContent): Promise<void> {
const r = await this.sendChatCommand({type: "addressAutoAccept", autoAccept, autoReply})
async enableAddressAutoAccept(acceptIncognito = false, autoReply?: CC.MsgContent): Promise<void> {
const r = await this.sendChatCommand({type: "addressAutoAccept", autoAccept: {acceptIncognito, autoReply}})
if (r.type !== "userContactLinkUpdated") {
throw new ChatCommandError("error changing user contact address mode", r)
}
}
async apiGetChats(): Promise<CR.Chat[]> {
const r = await this.sendChatCommand({type: "apiGetChats"})
async disableAddressAutoAccept(): Promise<void> {
const r = await this.sendChatCommand({type: "addressAutoAccept"})
if (r.type !== "userContactLinkUpdated") {
throw new ChatCommandError("error changing user contact address mode", r)
}
}
async apiGetChats(userId: number): Promise<CR.Chat[]> {
const r = await this.sendChatCommand({type: "apiGetChats", userId})
if (r.type === "apiChats") return r.chats
throw new ChatCommandError("error loading chats", r)
}
@@ -169,9 +176,14 @@ export class ChatClient {
throw new ChatCommandError("error updating chat item", r)
}
async apiDeleteChatItem(chatType: ChatType, chatId: number, chatItemId: number, deleteMode: CC.DeleteMode): Promise<CR.ChatItem> {
async apiDeleteChatItem(
chatType: ChatType,
chatId: number,
chatItemId: number,
deleteMode: CC.DeleteMode
): Promise<CR.ChatItem | undefined> {
const r = await this.sendChatCommand({type: "apiDeleteChatItem", chatType, chatId, chatItemId, deleteMode})
if (r.type === "chatItemDeleted") return r.toChatItem.chatItem
if (r.type === "chatItemDeleted") return r.toChatItem?.chatItem
throw new ChatCommandError("error deleting chat item", r)
}
@@ -215,8 +227,8 @@ export class ChatClient {
throw new ChatCommandError("error clearing chat", r)
}
async apiUpdateProfile(profile: CC.Profile): Promise<CC.Profile | undefined> {
const r = await this.sendChatCommand({type: "apiUpdateProfile", profile})
async apiUpdateProfile(userId: number, profile: CC.Profile): Promise<CC.Profile | undefined> {
const r = await this.sendChatCommand({type: "apiUpdateProfile", userId, profile})
switch (r.type) {
case "userProfileNoChange":
return undefined
@@ -1,9 +1,18 @@
export type ChatCommand =
| ShowActiveUser
| CreateActiveUser
| ListUsers
| APISetActiveUser
| APIHideUser
| APIUnhideUser
| APIMuteUser
| APIUnmuteUser
| APIDeleteUser
| StartChat
| APIStopChat
| SetTempFolder
| SetFilesFolder
| APISetXFTPConfig
| SetIncognito
| APIExportArchive
| APIImportArchive
@@ -13,6 +22,7 @@ export type ChatCommand =
| APISendMessage
| APIUpdateChatItem
| APIDeleteChatItem
| APIDeleteMemberChatItem
| APIChatRead
| APIDeleteChat
| APIClearChat
@@ -28,17 +38,31 @@ export type ChatCommand =
| APILeaveGroup
| APIListMembers
| APIUpdateGroupProfile
| GetUserSMPServers
| SetUserSMPServers
| APICreateGroupLink
| APIGroupLinkMemberRole
| APIDeleteGroupLink
| APIGetGroupLink
| APIGetUserProtoServers
| APISetUserProtoServers
| APIContactInfo
| APIGroupMemberInfo
| APIGetContactCode
| APIGetGroupMemberCode
| APIVerifyContact
| APIVerifyGroupMember
| AddContact
| Connect
| ConnectSimplex
| CreateMyAddress
| DeleteMyAddress
| ShowMyAddress
| SetProfileAddress
| AddressAutoAccept
| APICreateMyAddress
| APIDeleteMyAddress
| APIShowMyAddress
| APISetProfileAddress
| APIAddressAutoAccept
| ReceiveFile
| CancelFile
| FileStatus
@@ -64,6 +88,8 @@ export type ChatCommand =
// APIMemberRole -- not implemented
// ListContacts
// ListGroups
// APISetChatItemTTL
// APIGetChatItemTTL
// APISetNetworkConfig
// APIGetNetworkConfig
// APISetChatSettings
@@ -74,9 +100,19 @@ export type ChatCommand =
type ChatCommandTag =
| "showActiveUser"
| "createActiveUser"
| "listUsers"
| "apiSetActiveUser"
| "setActiveUser"
| "apiHideUser"
| "apiUnhideUser"
| "apiMuteUser"
| "apiUnmuteUser"
| "apiDeleteUser"
| "startChat"
| "apiStopChat"
| "setTempFolder"
| "setFilesFolder"
| "apiSetXFTPConfig"
| "setIncognito"
| "apiExportArchive"
| "apiImportArchive"
@@ -86,6 +122,7 @@ type ChatCommandTag =
| "apiSendMessage"
| "apiUpdateChatItem"
| "apiDeleteChatItem"
| "apiDeleteMemberChatItem"
| "apiChatRead"
| "apiDeleteChat"
| "apiClearChat"
@@ -101,17 +138,31 @@ type ChatCommandTag =
| "apiLeaveGroup"
| "apiListMembers"
| "apiUpdateGroupProfile"
| "getUserSMPServers"
| "setUserSMPServers"
| "apiCreateGroupLink"
| "apiGroupLinkMemberRole"
| "apiDeleteGroupLink"
| "apiGetGroupLink"
| "apiGetUserProtoServers"
| "apiSetUserProtoServers"
| "apiContactInfo"
| "apiGroupMemberInfo"
| "apiGetContactCode"
| "apiGetGroupMemberCode"
| "apiVerifyContact"
| "apiVerifyGroupMember"
| "addContact"
| "connect"
| "connectSimplex"
| "createMyAddress"
| "deleteMyAddress"
| "showMyAddress"
| "setProfileAddress"
| "addressAutoAccept"
| "apiCreateMyAddress"
| "apiDeleteMyAddress"
| "apiShowMyAddress"
| "apiSetProfileAddress"
| "apiAddressAutoAccept"
| "receiveFile"
| "cancelFile"
| "fileStatus"
@@ -126,24 +177,80 @@ export interface ShowActiveUser extends IChatCommand {
export interface CreateActiveUser extends IChatCommand {
type: "createActiveUser"
profile: Profile
profile?: Profile
sameServers: boolean
pastTimestamp: boolean
}
export interface ListUsers extends IChatCommand {
type: "listUsers"
}
export interface APISetActiveUser extends IChatCommand {
type: "apiSetActiveUser"
userId: number
viewPwd?: string
}
export interface APIHideUser extends IChatCommand {
type: "apiHideUser"
userId: number
viewPwd: string
}
export interface APIUnhideUser extends IChatCommand {
type: "apiUnhideUser"
userId: number
viewPwd: string
}
export interface APIMuteUser extends IChatCommand {
type: "apiMuteUser"
userId: number
}
export interface APIUnmuteUser extends IChatCommand {
type: "apiUnmuteUser"
userId: number
}
export interface APIDeleteUser extends IChatCommand {
type: "apiDeleteUser"
userId: number
delSMPQueues: boolean
viewPwd?: string
}
export interface StartChat extends IChatCommand {
type: "startChat"
subscribeConnections?: boolean
expireChatItems?: boolean
enableExpireChatItems?: boolean
startXFTPWorkers?: boolean
}
export interface APIStopChat extends IChatCommand {
type: "apiStopChat"
}
export interface SetTempFolder extends IChatCommand {
type: "setTempFolder"
tempFolder: string
}
export interface SetFilesFolder extends IChatCommand {
type: "setFilesFolder"
filePath: string
}
export interface APISetXFTPConfig extends IChatCommand {
type: "apiSetXFTPConfig"
config?: XFTPFileConfig
}
export interface XFTPFileConfig {
minFileSize: number
}
export interface SetIncognito extends IChatCommand {
type: "setIncognito"
incognito: boolean
@@ -165,6 +272,7 @@ export interface APIDeleteStorage extends IChatCommand {
export interface APIGetChats extends IChatCommand {
type: "apiGetChats"
userId: number
pendingConnections?: boolean
}
@@ -205,6 +313,13 @@ export interface APIDeleteChatItem extends IChatCommand {
deleteMode: DeleteMode
}
export interface APIDeleteMemberChatItem extends IChatCommand {
type: "apiDeleteMemberChatItem"
groupId: number
groupMemberId: number
itemId: number
}
export interface APIChatRead extends IChatCommand {
type: "apiChatRead"
chatType: ChatType
@@ -241,6 +356,7 @@ export interface APIRejectContact extends IChatCommand {
export interface APIUpdateProfile extends IChatCommand {
type: "apiUpdateProfile"
userId: number
profile: Profile
}
@@ -294,13 +410,51 @@ export interface APIUpdateGroupProfile extends IChatCommand {
groupProfile: GroupProfile
}
export interface GetUserSMPServers extends IChatCommand {
type: "getUserSMPServers"
export interface APICreateGroupLink extends IChatCommand {
type: "apiCreateGroupLink"
groupId: number
memberRole: GroupMemberRole
}
export interface SetUserSMPServers extends IChatCommand {
type: "setUserSMPServers"
servers: [string]
export interface APIGroupLinkMemberRole extends IChatCommand {
type: "apiGroupLinkMemberRole"
groupId: number
memberRole: GroupMemberRole
}
export interface APIDeleteGroupLink extends IChatCommand {
type: "apiDeleteGroupLink"
groupId: number
}
export interface APIGetGroupLink extends IChatCommand {
type: "apiGetGroupLink"
groupId: number
}
export interface APIGetUserProtoServers extends IChatCommand {
type: "apiGetUserProtoServers"
userId: number
serverProtocol: ServerProtocol
}
export interface APISetUserProtoServers extends IChatCommand {
type: "apiSetUserProtoServers"
userId: number
serverProtocol: ServerProtocol
servers: ServerCfg[]
}
export interface ServerCfg {
server: string
preset: boolean
tested?: boolean
enabled: boolean
}
export enum ServerProtocol {
SMP = "smp",
XFTP = "xftp",
}
export interface APIContactInfo extends IChatCommand {
@@ -314,6 +468,30 @@ export interface APIGroupMemberInfo extends IChatCommand {
memberId: number
}
export interface APIGetContactCode extends IChatCommand {
type: "apiGetContactCode"
contactId: number
}
export interface APIGetGroupMemberCode extends IChatCommand {
type: "apiGetGroupMemberCode"
groupId: number
groupMemberId: number
}
export interface APIVerifyContact extends IChatCommand {
type: "apiVerifyContact"
contactId: number
connectionCode: string
}
export interface APIVerifyGroupMember extends IChatCommand {
type: "apiVerifyGroupMember"
groupId: number
groupMemberId: number
connectionCode: string
}
export interface AddContact extends IChatCommand {
type: "addContact"
}
@@ -339,9 +517,45 @@ export interface ShowMyAddress extends IChatCommand {
type: "showMyAddress"
}
export interface SetProfileAddress extends IChatCommand {
type: "setProfileAddress"
includeInProfile: boolean
}
export interface AddressAutoAccept extends IChatCommand {
type: "addressAutoAccept"
autoAccept: boolean
autoAccept?: AutoAccept
}
export interface APICreateMyAddress extends IChatCommand {
type: "apiCreateMyAddress"
userId: number
}
export interface APIDeleteMyAddress extends IChatCommand {
type: "apiDeleteMyAddress"
userId: number
}
export interface APIShowMyAddress extends IChatCommand {
type: "apiShowMyAddress"
userId: number
}
export interface APISetProfileAddress extends IChatCommand {
type: "apiSetProfileAddress"
userId: number
includeInProfile: boolean
}
export interface APIAddressAutoAccept extends IChatCommand {
type: "apiAddressAutoAccept"
userId: number
autoAccept?: AutoAccept
}
export interface AutoAccept {
acceptIncognito: boolean
autoReply?: MsgContent
}
@@ -361,10 +575,28 @@ export interface FileStatus extends IChatCommand {
fileId: number
}
interface NewUser {
profile?: Profile
sameServers: boolean
pastTimestamp: boolean
}
export interface Profile {
displayName: string
fullName: string // can be empty string
image?: string
contactLink?: string
// preferences?: Preferences
}
export interface LocalProfile {
profileId: number
displayName: string
fullName: string
image?: string
contactLink?: string
// preferences?: Preferences
localAlias: string
}
export enum ChatType {
@@ -449,16 +681,36 @@ export function cmdString(cmd: ChatCommand): string {
switch (cmd.type) {
case "showActiveUser":
return "/u"
case "createActiveUser":
return `/create user ${JSON.stringify(cmd.profile)}`
case "createActiveUser": {
const user: NewUser = {profile: cmd.profile, sameServers: cmd.sameServers, pastTimestamp: cmd.pastTimestamp}
return `/_create user ${JSON.stringify(user)}`
}
case "listUsers":
return `/users`
case "apiSetActiveUser":
return `/_user ${cmd.userId}${maybeJSON(cmd.viewPwd)}`
case "apiHideUser":
return `/_hide user ${cmd.userId} ${JSON.stringify(cmd.viewPwd)}`
case "apiUnhideUser":
return `/_unhide user ${cmd.userId} ${JSON.stringify(cmd.viewPwd)}`
case "apiMuteUser":
return `/_mute user ${cmd.userId}`
case "apiUnmuteUser":
return `/_unmute user ${cmd.userId}`
case "apiDeleteUser":
return `/_delete user ${cmd.userId} del_smp=${onOff(cmd.delSMPQueues)}${maybeJSON(cmd.viewPwd)}`
case "startChat":
return `/_start subscribe=${cmd.subscribeConnections ? "on" : "off"} expire=${cmd.expireChatItems ? "on" : "off"}`
return `/_start subscribe=${cmd.subscribeConnections ? "on" : "off"} expire=${cmd.enableExpireChatItems ? "on" : "off"}`
case "apiStopChat":
return "/_stop"
case "setTempFolder":
return `/_temp_folder ${cmd.tempFolder}`
case "setFilesFolder":
return `/_files_folder ${cmd.filePath}`
case "apiSetXFTPConfig":
return `/_xftp ${onOff(cmd.config)}${maybeJSON(cmd.config)}`
case "setIncognito":
return `/incognito ${cmd.incognito ? "on" : "off"}`
return `/incognito ${onOff(cmd.incognito)}`
case "apiExportArchive":
return `/_db export ${JSON.stringify(cmd.config)}`
case "apiImportArchive":
@@ -466,7 +718,7 @@ export function cmdString(cmd: ChatCommand): string {
case "apiDeleteStorage":
return "/_db delete"
case "apiGetChats":
return `/_get chats pcc=${cmd.pendingConnections ? "on" : "off"}`
return `/_get chats pcc=${onOff(cmd.pendingConnections)}`
case "apiGetChat":
return `/_get chat ${cmd.chatType}${cmd.chatId}${paginationStr(cmd.pagination)}`
case "apiSendMessage":
@@ -475,6 +727,8 @@ export function cmdString(cmd: ChatCommand): string {
return `/_update item ${cmd.chatType}${cmd.chatId} ${cmd.chatItemId} json ${JSON.stringify(cmd.msgContent)}`
case "apiDeleteChatItem":
return `/_delete item ${cmd.chatType}${cmd.chatId} ${cmd.chatItemId} ${cmd.deleteMode}`
case "apiDeleteMemberChatItem":
return `/_delete member item #${cmd.groupId} ${cmd.groupMemberId} ${cmd.itemId}`
case "apiChatRead": {
const itemRange = cmd.itemRange ? ` from=${cmd.itemRange.fromItem} to=${cmd.itemRange.toItem}` : ""
return `/_read chat ${cmd.chatType}${cmd.chatId}${itemRange}`
@@ -488,7 +742,7 @@ export function cmdString(cmd: ChatCommand): string {
case "apiRejectContact":
return `/_reject ${cmd.contactReqId}`
case "apiUpdateProfile":
return `/_profile ${JSON.stringify(cmd.profile)}`
return `/_profile ${cmd.userId} ${JSON.stringify(cmd.profile)}`
case "apiSetContactAlias":
return `/_set alias @${cmd.contactId} ${cmd.localAlias.trim()}`
case "apiParseMarkdown":
@@ -507,14 +761,30 @@ export function cmdString(cmd: ChatCommand): string {
return `/_members #${cmd.groupId}`
case "apiUpdateGroupProfile":
return `/_group_profile #${cmd.groupId} ${JSON.stringify(cmd.groupProfile)}`
case "getUserSMPServers":
return "/smp_servers"
case "setUserSMPServers":
return `/smp_servers ${cmd.servers.join(",") || "default"}`
case "apiCreateGroupLink":
return `/_create link #${cmd.groupId} ${cmd.memberRole}`
case "apiGroupLinkMemberRole":
return `/_set link role #${cmd.groupId} ${cmd.memberRole}`
case "apiDeleteGroupLink":
return `/_delete link #${cmd.groupId}`
case "apiGetGroupLink":
return `/_get link #${cmd.groupId}`
case "apiGetUserProtoServers":
return `/_servers ${cmd.userId} ${cmd.serverProtocol}`
case "apiSetUserProtoServers":
return `/_servers ${cmd.userId} ${cmd.serverProtocol} ${JSON.stringify({servers: cmd.servers})}`
case "apiContactInfo":
return `/_info @${cmd.contactId}`
case "apiGroupMemberInfo":
return `/_info #${cmd.groupId} ${cmd.memberId}`
case "apiGetContactCode":
return `/_get code @${cmd.contactId}`
case "apiGetGroupMemberCode":
return `/_get code #${cmd.groupId} ${cmd.groupMemberId}`
case "apiVerifyContact":
return `/_verify code @${cmd.contactId}${maybe(cmd.connectionCode)}`
case "apiVerifyGroupMember":
return `/_verify code #${cmd.groupId} ${cmd.groupMemberId}${maybe(cmd.connectionCode)}`
case "addContact":
return "/connect"
case "connect":
@@ -527,8 +797,20 @@ export function cmdString(cmd: ChatCommand): string {
return "/delete_address"
case "showMyAddress":
return "/show_address"
case "setProfileAddress":
return `/profile_address ${onOff(cmd.includeInProfile)}`
case "addressAutoAccept":
return `/auto_accept ${cmd.autoAccept ? "on" : "off"}${cmd.autoReply ? " " + JSON.stringify(cmd.autoReply) : ""}`
return `/auto_accept ${autoAcceptStr(cmd.autoAccept)}`
case "apiCreateMyAddress":
return `/_address ${cmd.userId}`
case "apiDeleteMyAddress":
return `/_delete_address ${cmd.userId}`
case "apiShowMyAddress":
return `/_show_address ${cmd.userId}`
case "apiSetProfileAddress":
return `/_profile_address ${cmd.userId} ${onOff(cmd.includeInProfile)}`
case "apiAddressAutoAccept":
return `/_auto_accept ${cmd.userId} ${autoAcceptStr(cmd.autoAccept)}`
case "receiveFile":
return `/freceive ${cmd.fileId}${cmd.filePath ? " " + cmd.filePath : ""}`
case "cancelFile":
@@ -542,3 +824,21 @@ function paginationStr(cp: ChatPagination): string {
const base = "after" in cp ? ` after=${cp.after}` : "before" in cp ? ` before=${cp.before}` : ""
return base + ` count=${cp.count}`
}
function maybe<T>(value: T | undefined): string {
return value ? ` ${value}` : ""
}
function maybeJSON<T>(value: T | undefined): string {
return value ? ` json ${JSON.stringify(value)}` : ""
}
function onOff<T>(value: T | undefined): string {
return value ? "on" : "off"
}
function autoAcceptStr(autoAccept: AutoAccept | undefined): string {
if (!autoAccept) return "off"
const msg = autoAccept.autoReply
return "on" + (autoAccept.acceptIncognito ? " incognito=on" : "") + (msg ? " json " + JSON.stringify(msg) : "")
}
@@ -1,14 +1,15 @@
import {ChatItemId, MsgContent, DeleteMode, Profile, GroupMemberRole} from "./command"
import {ChatItemId, MsgContent, DeleteMode, Profile, GroupMemberRole, LocalProfile, ServerProtocol, ServerCfg} from "./command"
export type ChatResponse =
| CRActiveUser
| CRUsersList
| CRChatStarted
| CRChatRunning
| CRChatStopped
| CRApiChats
| CRApiChat
| CRApiParsedMarkdown
| CRUserSMPServers
| CRUserProtoServers
| CRContactInfo
| CRGroupMemberInfo
| CRNewChatItem
@@ -40,8 +41,6 @@ export type ChatResponse =
| CRContactConnecting
| CRContactConnected
| CRContactAnotherClient
| CRContactDisconnected
| CRContactSubscribed
| CRContactSubError
| CRContactSubSummary
| CRContactsDisconnected
@@ -101,13 +100,14 @@ export type ChatResponse =
type ChatResponseTag =
| "activeUser"
| "usersList"
| "chatStarted"
| "chatRunning"
| "chatStopped"
| "apiChats"
| "apiChat"
| "apiParsedMarkdown"
| "userSMPServers"
| "userProtoServers"
| "contactInfo"
| "groupMemberInfo"
| "newChatItem"
@@ -139,8 +139,6 @@ type ChatResponseTag =
| "contactConnecting"
| "contactConnected"
| "contactAnotherClient"
| "contactDisconnected"
| "contactSubscribed"
| "contactSubError"
| "contactSubSummary"
| "contactsDisconnected"
@@ -202,6 +200,11 @@ export interface CRActiveUser extends CR {
user: User
}
export interface CRUsersList extends CR {
type: "usersList"
users: UserInfo[]
}
export interface CRChatStarted extends CR {
type: "chatStarted"
}
@@ -216,11 +219,13 @@ export interface CRChatStopped extends CR {
export interface CRApiChats extends CR {
type: "apiChats"
user: User
chats: Chat[]
}
export interface CRApiChat extends CR {
type: "apiChat"
user: User
chat: Chat
}
@@ -229,13 +234,15 @@ export interface CRApiParsedMarkdown extends CR {
formattedText?: FormattedText[]
}
export interface CRUserSMPServers extends CR {
type: "userSMPServers"
smpServers: string[]
export interface CRUserProtoServers extends CR {
type: "userProtoServers"
user: User
servers: UserProtoServers
}
export interface CRContactInfo extends CR {
type: "contactInfo"
user: User
contact: Contact
connectionStats: ConnectionStats
customUserProfile?: Profile
@@ -243,6 +250,7 @@ export interface CRContactInfo extends CR {
export interface CRGroupMemberInfo extends CR {
type: "groupMemberInfo"
user: User
groupInfo: GroupInfo
member: GroupMember
connectionStats_?: ConnectionStats
@@ -250,21 +258,25 @@ export interface CRGroupMemberInfo extends CR {
export interface CRNewChatItem extends CR {
type: "newChatItem"
user: User
chatItem: AChatItem
}
export interface CRChatItemStatusUpdated extends CR {
type: "chatItemStatusUpdated"
user: User
chatItem: AChatItem
}
export interface CRChatItemUpdated extends CR {
type: "chatItemUpdated"
user: User
chatItem: AChatItem
}
export interface CRChatItemDeleted extends CR {
type: "chatItemDeleted"
user: User
deletedChatItem: AChatItem
toChatItem?: AChatItem
byUser: boolean
@@ -272,20 +284,24 @@ export interface CRChatItemDeleted extends CR {
export interface CRMsgIntegrityError extends CR {
type: "msgIntegrityError"
user: User
msgError: MsgErrorType
}
export interface CRCmdOk extends CR {
type: "cmdOk"
user_?: User
}
export interface CRUserContactLink extends CR {
type: "userContactLink"
user: User
contactLink: UserContactLink
}
export interface CRUserContactLinkUpdated extends CR {
type: "userContactLinkUpdated"
user: User
connReqContact: string
autoAccept: boolean
autoReply?: MsgContent
@@ -293,138 +309,153 @@ export interface CRUserContactLinkUpdated extends CR {
export interface CRContactRequestRejected extends CR {
type: "contactRequestRejected"
user: User
contactRequest: UserContactRequest
}
export interface CRUserProfile extends CR {
type: "userProfile"
user: User
profile: Profile
}
export interface CRUserProfileNoChange extends CR {
type: "userProfileNoChange"
user: User
}
export interface CRUserProfileUpdated extends CR {
type: "userProfileUpdated"
user: User
fromProfile: Profile
toProfile: Profile
}
export interface CRContactAliasUpdated extends CR {
type: "contactAliasUpdated"
user: User
toContact: Contact
}
export interface CRInvitation extends CR {
type: "invitation"
user: User
connReqInvitation: string
}
export interface CRSentConfirmation extends CR {
type: "sentConfirmation"
user: User
}
export interface CRSentInvitation extends CR {
type: "sentInvitation"
user: User
}
export interface CRContactUpdated extends CR {
type: "contactUpdated"
user: User
fromContact: Contact
toContact: Contact
}
export interface CRContactsMerged extends CR {
type: "contactsMerged"
user: User
intoContact: Contact
mergedContact: Contact
}
export interface CRContactDeleted extends CR {
type: "contactDeleted"
user: User
contact: Contact
}
export interface CRChatCleared extends CR {
type: "chatCleared"
user: User
chatInfo: ChatInfo
}
export interface CRUserContactLinkCreated extends CR {
type: "userContactLinkCreated"
user: User
connReqContact: string
}
export interface CRUserContactLinkDeleted extends CR {
type: "userContactLinkDeleted"
user: User
}
export interface CRReceivedContactRequest extends CR {
type: "receivedContactRequest"
user: User
contactRequest: UserContactRequest
}
export interface CRAcceptingContactRequest extends CR {
type: "acceptingContactRequest"
user: User
contact: Contact
}
export interface CRContactAlreadyExists extends CR {
type: "contactAlreadyExists"
user: User
contact: Contact
}
export interface CRContactRequestAlreadyAccepted extends CR {
type: "contactRequestAlreadyAccepted"
user: User
contact: Contact
}
export interface CRContactConnecting extends CR {
type: "contactConnecting"
user: User
contact: Contact
}
export interface CRContactConnected extends CR {
type: "contactConnected"
contact: Contact
user: User
userCustomProfile?: Profile
}
export interface CRContactAnotherClient extends CR {
type: "contactAnotherClient"
contact: Contact
}
export interface CRContactDisconnected extends CR {
type: "contactDisconnected"
contact: Contact
}
export interface CRContactSubscribed extends CR {
type: "contactSubscribed"
user: User
contact: Contact
}
export interface CRContactSubError extends CR {
type: "contactSubError"
user: User
contact: Contact
chatError: ChatError
}
export interface CRContactSubSummary extends CR {
type: "contactSubSummary"
user: User
contactSubscriptions: ContactSubStatus[]
}
export interface CRContactsDisconnected extends CR {
type: "contactsDisconnected"
user: User
server: string
contactRefs: ContactRef[]
}
export interface CRContactsSubscribed extends CR {
type: "contactsSubscribed"
user: User
server: string
contactRefs: ContactRef[]
}
@@ -443,11 +474,13 @@ export interface CRHostDisconnected extends CR {
export interface CRGroupEmpty extends CR {
type: "groupEmpty"
user: User
groupInfo: GroupInfo
}
export interface CRMemberSubError extends CR {
type: "memberSubError"
user: User
groupInfo: GroupInfo
member: GroupMember
chatError: ChatError
@@ -455,70 +488,83 @@ export interface CRMemberSubError extends CR {
export interface CRMemberSubSummary extends CR {
type: "memberSubSummary"
user: User
memberSubscriptions: MemberSubStatus[]
}
export interface CRGroupSubscribed extends CR {
type: "groupSubscribed"
user: User
groupInfo: GroupInfo
}
export interface CRRcvFileAccepted extends CR {
type: "rcvFileAccepted"
user: User
chatItem: AChatItem
}
export interface CRRcvFileAcceptedSndCancelled extends CR {
type: "rcvFileAcceptedSndCancelled"
user: User
rcvFileTransfer: RcvFileTransfer
}
export interface CRRcvFileStart extends CR {
type: "rcvFileStart"
user: User
chatItem: AChatItem
}
export interface CRRcvFileComplete extends CR {
type: "rcvFileComplete"
user: User
chatItem: AChatItem
}
export interface CRRcvFileCancelled extends CR {
type: "rcvFileCancelled"
user: User
rcvFileTransfer: RcvFileTransfer
}
export interface CRRcvFileSndCancelled extends CR {
type: "rcvFileSndCancelled"
user: User
rcvFileTransfer: RcvFileTransfer
}
export interface CRSndFileStart extends CR {
type: "sndFileStart"
user: User
chatItem: AChatItem
sndFileTransfer: SndFileTransfer
}
export interface CRSndFileComplete extends CR {
type: "sndFileComplete"
user: User
chatItem: AChatItem
sndFileTransfer: SndFileTransfer
}
export interface CRSndFileCancelled extends CR {
type: "sndFileCancelled"
user: User
chatItem: AChatItem
sndFileTransfer: SndFileTransfer
}
export interface CRSndFileRcvCancelled extends CR {
type: "sndFileRcvCancelled"
user: User
chatItem: AChatItem
sndFileTransfer: SndFileTransfer
}
export interface CRSndGroupFileCancelled extends CR {
type: "sndGroupFileCancelled"
user: User
chatItem: AChatItem
fileTransferMeta: FileTransferMeta
sndFileTransfers: SndFileTransfer[]
@@ -526,45 +572,53 @@ export interface CRSndGroupFileCancelled extends CR {
export interface CRSndFileSubError extends CR {
type: "sndFileSubError"
user: User
sndFileTransfer: SndFileTransfer
chatError: ChatError
}
export interface CRRcvFileSubError extends CR {
type: "rcvFileSubError"
user: User
rcvFileTransfer: RcvFileTransfer
chatError: ChatError
}
export interface CRPendingSubSummary extends CR {
type: "pendingSubSummary"
user: User
pendingSubStatus: PendingSubStatus[]
}
export interface CRGroupCreated extends CR {
type: "groupCreated"
user: User
groupInfo: GroupInfo
}
export interface CRGroupMembers extends CR {
type: "groupMembers"
user: User
group: Group
}
export interface CRUserAcceptedGroupSent extends CR {
type: "userAcceptedGroupSent"
user: User
groupInfo: GroupInfo
hostContact?: Contact // included when joining group via group link
}
export interface CRUserDeletedMember extends CR {
type: "userDeletedMember"
user: User
groupInfo: GroupInfo
member: GroupMember
}
export interface CRSentGroupInvitation extends CR {
type: "sentGroupInvitation"
user: User
groupInfo: GroupInfo
contact: Contact
member: GroupMember
@@ -572,21 +626,25 @@ export interface CRSentGroupInvitation extends CR {
export interface CRLeftMemberUser extends CR {
type: "leftMemberUser"
user: User
groupInfo: GroupInfo
}
export interface CRGroupDeletedUser extends CR {
type: "groupDeletedUser"
user: User
groupInfo: GroupInfo
}
export interface CRGroupInvitation extends CR {
type: "groupInvitation"
user: User
groupInfo: GroupInfo
}
export interface CRReceivedGroupInvitation extends CR {
type: "receivedGroupInvitation"
user: User
groupInfo: GroupInfo
contact: Contact
memberRole: GroupMemberRole
@@ -594,18 +652,21 @@ export interface CRReceivedGroupInvitation extends CR {
export interface CRUserJoinedGroup extends CR {
type: "userJoinedGroup"
user: User
groupInfo: GroupInfo
hostMember: GroupMember
}
export interface CRJoinedGroupMember extends CR {
type: "joinedGroupMember"
user: User
groupInfo: GroupInfo
member: GroupMember
}
export interface CRJoinedGroupMemberConnecting extends CR {
type: "joinedGroupMemberConnecting"
user: User
groupInfo: GroupInfo
hostMember: GroupMember
member: GroupMember
@@ -613,12 +674,14 @@ export interface CRJoinedGroupMemberConnecting extends CR {
export interface CRConnectedToGroupMember extends CR {
type: "connectedToGroupMember"
user: User
groupInfo: GroupInfo
member: GroupMember
}
export interface CRDeletedMember extends CR {
type: "deletedMember"
user: User
groupInfo: GroupInfo
byMember: GroupMember
deletedMember: GroupMember
@@ -626,29 +689,34 @@ export interface CRDeletedMember extends CR {
export interface CRDeletedMemberUser extends CR {
type: "deletedMemberUser"
user: User
groupInfo: GroupInfo
member: GroupMember
}
export interface CRLeftMember extends CR {
type: "leftMember"
user: User
groupInfo: GroupInfo
member: GroupMember
}
export interface CRGroupRemoved extends CR {
type: "groupRemoved"
user: User
groupInfo: GroupInfo
}
export interface CRGroupDeleted extends CR {
type: "groupDeleted"
user: User
groupInfo: GroupInfo
member: GroupMember
}
export interface CRGroupUpdated extends CR {
type: "groupUpdated"
user: User
fromGroup: GroupInfo
toGroup: GroupInfo
member_?: GroupMember
@@ -665,36 +733,51 @@ export interface CRUserContactLinkSubError extends CR {
export interface CRNewContactConnection extends CR {
type: "newContactConnection"
user: User
connection: PendingContactConnection
}
export interface CRContactConnectionDeleted extends CR {
type: "contactConnectionDeleted"
user: User
connection: PendingContactConnection
}
export interface CRMessageError extends CR {
type: "messageError"
user: User
severity: string
errorMessage: string
}
export interface CRChatCmdError extends CR {
type: "chatCmdError"
user_?: User
chatError: ChatError
}
export interface CRChatError extends CR {
type: "chatError"
user_?: User
chatError: ChatError
}
export interface User {
userId: number
agentUserId: string
userContactId: number
localDisplayName: string
profile: Profile
profile: LocalProfile
// fullPreferences :: FullPreferences
activeUser: boolean
viewPwdHash: string
showNtfs: boolean
}
export interface UserProtoServers {
serverProtocol: ServerProtocol
protoServers: ServerCfg[]
presetServers: string
}
export interface Chat {
@@ -730,6 +813,16 @@ interface CInfoContactRequest extends IChatInfo {
contactRequest: UserContactRequest
}
export interface UserPwdHash {
hash: string
salt: string
}
interface UserInfo {
user: User
unreadCount: number
}
export interface Contact {
contactId: number
localDisplayName: string
+1
View File
@@ -95,6 +95,7 @@ library
Simplex.Chat.Migrations.M20230422_profile_contact_links
Simplex.Chat.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages
Simplex.Chat.Migrations.M20230505_chat_item_versions
Simplex.Chat.Migrations.M20230511_reactions
Simplex.Chat.Mobile
Simplex.Chat.Mobile.WebRTC
Simplex.Chat.Options
+154 -39
View File
@@ -114,6 +114,7 @@ defaultChatConfig =
inlineFiles = defaultInlineFilesConfig,
xftpFileConfig = Just defaultXFTPFileConfig,
tempDir = Nothing,
showReactions = False,
logLevel = CLLImportant,
subscriptionEvents = False,
hostEvents = False,
@@ -135,6 +136,9 @@ _defaultNtfServers = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.s
maxImageSize :: Integer
maxImageSize = 236700
maxMsgReactions :: Int
maxMsgReactions = 3
fixedImagePreview :: ImageData
fixedImagePreview = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="
@@ -148,9 +152,9 @@ createChatDatabase filePrefix key confirmMigrations = runExceptT $ do
pure ChatDatabase {chatStore, agentStore}
newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController
newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize}, optFilesFolder, allowInstantFiles} sendToast = do
newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize}, optFilesFolder, showReactions, allowInstantFiles} sendToast = do
let inlineFiles' = if allowInstantFiles then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False}
config = cfg {logLevel, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles'}
config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles'}
sendNotification = fromMaybe (const $ pure ()) sendToast
firstTime = dbNew chatStore
activeTo <- newTVarIO ActiveNone
@@ -728,6 +732,52 @@ processChatCommand = \case
SndMessage {msgId} <- sendGroupMessage user gInfo ms $ XMsgDel itemSharedMId $ Just memberId
delGroupChatItem user gInfo ci msgId (Just membership)
(_, _) -> throwChatError CEInvalidChatItemDelete
APIChatItemReaction (ChatRef cType chatId) itemId reaction add -> withUser $ \user -> withChatLock "chatItemReaction" $ case cType of
CTDirect ->
withStore (\db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId) >>= \case
(ct, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do
unless (featureAllowed SCFReactions forUser ct) $
throwChatError $ CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)
unless (ciReactionAllowed ci) $
throwChatError $ CECommandError "reaction not allowed - chat item has no content"
rs <- withStore' $ \db -> getDirectReactions db ct itemSharedMId True
checkReactionAllowed rs
(SndMessage {msgId}, _) <- sendDirectContactMessage ct $ XMsgReact itemSharedMId Nothing reaction add
createdAt <- liftIO getCurrentTime
reactions <- withStore' $ \db -> do
setDirectReaction db ct itemSharedMId True reaction add msgId createdAt
liftIO $ getDirectCIReactions db ct itemSharedMId
let ci' = CChatItem md ci {reactions}
r = ACIReaction SCTDirect SMDSnd (DirectChat ct) $ CIReaction CIDirectSnd ci' createdAt reaction
pure $ CRChatItemReaction user r add
_ -> throwChatError $ CECommandError "reaction not possible - no shared item ID"
CTGroup ->
withStore (\db -> (,) <$> getGroup db user chatId <*> getGroupChatItem db user chatId itemId) >>= \case
(Group g@GroupInfo {membership} ms, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do
unless (groupFeatureAllowed SGFReactions g) $
throwChatError $ CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)
unless (ciReactionAllowed ci) $
throwChatError $ CECommandError "reaction not allowed - chat item has no content"
let GroupMember {memberId = itemMemberId} = chatItemMember g ci
rs <- withStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True
checkReactionAllowed rs
SndMessage {msgId} <- sendGroupMessage user g ms (XMsgReact itemSharedMId (Just itemMemberId) reaction add)
createdAt <- liftIO getCurrentTime
reactions <- withStore' $ \db -> do
setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt
liftIO $ getGroupCIReactions db g itemMemberId itemSharedMId
let ci' = CChatItem md ci {reactions}
r = ACIReaction SCTGroup SMDSnd (GroupChat g) $ CIReaction CIGroupSnd ci' createdAt reaction
pure $ CRChatItemReaction user r add
_ -> throwChatError $ CECommandError "reaction not possible - no shared item ID"
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
where
checkReactionAllowed rs = do
when ((reaction `elem` rs) == add) $
throwChatError $ CECommandError $ "reaction already " <> if add then "added" else "removed"
when (add && length rs >= maxMsgReactions) $
throwChatError $ CECommandError "too many reactions"
APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of
CTDirect -> do
user <- withStore $ \db -> getUserByContactId db chatId
@@ -1229,6 +1279,10 @@ processChatCommand = \case
chatRef <- getChatRef user chatName
let mc = MCText msg
processChatCommand $ APIUpdateChatItem chatRef chatItemId live mc
ReactToMessage chatName msg reaction add -> withUser $ \user -> do
chatRef <- getChatRef user chatName
chatItemId <- getChatItemIdByText user chatRef msg
processChatCommand $ APIChatItemReaction chatRef chatItemId reaction add
APINewGroup userId gProfile -> withUserId userId $ \user -> do
gVar <- asks idsDrg
groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile
@@ -2650,6 +2704,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
XMsgFileCancel sharedMsgId -> cancelMessageFile ct sharedMsgId msgMeta
XMsgUpdate sharedMsgId mContent ttl live -> messageUpdate ct sharedMsgId mContent msg msgMeta ttl live
XMsgDel sharedMsgId _ -> messageDelete ct sharedMsgId msg msgMeta
XMsgReact sharedMsgId _ reaction add -> directMsgReaction ct sharedMsgId reaction add msg msgMeta
-- TODO discontinue XFile
XFile fInv -> processFileInvitation' ct fInv msg msgMeta
XFileCancel sharedMsgId -> xFileCancel ct sharedMsgId msgMeta
@@ -2880,6 +2935,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
XMsgFileCancel sharedMsgId -> cancelGroupMessageFile gInfo m sharedMsgId msgMeta
XMsgUpdate sharedMsgId mContent ttl live -> canSend $ groupMessageUpdate gInfo m sharedMsgId mContent msg msgMeta ttl live
XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo m sharedMsgId memberId msg
XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo m sharedMsgId memberId reaction add msg msgMeta
-- TODO discontinue XFile
XFile fInv -> processGroupFileInvitation' gInfo m fInv msg msgMeta
XFileCancel sharedMsgId -> xFileCancelGroup gInfo m sharedMsgId msgMeta
@@ -3260,7 +3316,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
where
newChatItem ciContent ciFile_ timed_ live = do
ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getDirectCIReactions db ct sharedMsgId) sharedMsgId_
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions})
pure ci
messageFileDescription :: Contact -> SharedMsgId -> FileDescr -> MsgMeta -> m ()
@@ -3316,20 +3373,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m ()
messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
updateRcvChatItem `catchError` \e ->
case e of
(ChatErrorStore (SEChatItemSharedMsgIdNotFound _)) -> do
-- This patches initial sharedMsgId into chat item when locally deleted chat item
-- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete).
-- Chat item and update message which created it will have different sharedMsgId in this case...
let timed_ = rcvContactCITimed ct ttl
ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) msgMeta content Nothing timed_ live
ci' <- withStore' $ \db -> do
createChatItemVersion db (chatItemId' ci) brokerTs mc
updateDirectChatItem' db user contactId ci content live Nothing
toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci')
setActive $ ActiveC c
_ -> throwError e
updateRcvChatItem `catchCINotFound` \_ -> do
-- This patches initial sharedMsgId into chat item when locally deleted chat item
-- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete).
-- Chat item and update message which created it will have different sharedMsgId in this case...
let timed_ = rcvContactCITimed ct ttl
ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) msgMeta content Nothing timed_ live
ci' <- withStore' $ \db -> do
createChatItemVersion db (chatItemId' ci) brokerTs mc
updateDirectChatItem' db user contactId ci content live Nothing
toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci')
setActive $ ActiveC c
where
MsgMeta {broker = (_, brokerTs)} = msgMeta
content = CIRcvMsgContent mc
@@ -3353,10 +3407,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> m ()
messageDelete ct@Contact {contactId} sharedMsgId RcvMessage {msgId} msgMeta = do
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
deleteRcvChatItem `catchError` \e ->
case e of
(ChatErrorStore (SEChatItemSharedMsgIdNotFound sMsgId)) -> toView $ CRChatItemDeletedNotFound user ct sMsgId
_ -> throwError e
deleteRcvChatItem `catchCINotFound` (toView . CRChatItemDeletedNotFound user ct)
where
deleteRcvChatItem = do
ci@(CChatItem msgDir _) <- withStore $ \db -> getDirectChatItemBySharedMsgId db user contactId sharedMsgId
@@ -3367,8 +3418,60 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
else markDirectCIDeleted user ct ci msgId False >>= toView
SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete"
directMsgReaction :: Contact -> SharedMsgId -> MsgReaction -> Bool -> RcvMessage -> MsgMeta -> m ()
directMsgReaction ct sharedMsgId reaction add RcvMessage {msgId} MsgMeta {broker = (_, brokerTs)} = do
when (featureAllowed SCFReactions forContact ct) $ do
rs <- withStore' $ \db -> getDirectReactions db ct sharedMsgId False
when (reactionAllowed add reaction rs) $ do
updateChatItemReaction `catchCINotFound` \_ ->
withStore' $ \db -> setDirectReaction db ct sharedMsgId False reaction add msgId brokerTs
where
updateChatItemReaction = do
cr_ <- withStore $ \db -> do
CChatItem md ci <- getDirectChatItemBySharedMsgId db user (contactId' ct) sharedMsgId
if ciReactionAllowed ci
then liftIO $ do
setDirectReaction db ct sharedMsgId False reaction add msgId brokerTs
reactions <- getDirectCIReactions db ct sharedMsgId
let ci' = CChatItem md ci {reactions}
r = ACIReaction SCTDirect SMDRcv (DirectChat ct) $ CIReaction CIDirectRcv ci' brokerTs reaction
pure $ Just $ CRChatItemReaction user r add
else pure Nothing
mapM_ toView cr_
groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> MsgReaction -> Bool -> RcvMessage -> MsgMeta -> m ()
groupMsgReaction g@GroupInfo {groupId} m sharedMsgId itemMemberId reaction add RcvMessage {msgId} MsgMeta {broker = (_, brokerTs)} = do
when (groupFeatureAllowed SGFReactions g) $ do
rs <- withStore' $ \db -> getGroupReactions db g m itemMemberId sharedMsgId False
when (reactionAllowed add reaction rs) $ do
updateChatItemReaction `catchCINotFound` \_ ->
withStore' $ \db -> setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs
where
updateChatItemReaction = do
cr_ <- withStore $ \db -> do
CChatItem md ci <- getGroupMemberCIBySharedMsgId db user groupId itemMemberId sharedMsgId
if ciReactionAllowed ci
then liftIO $ do
setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs
reactions <- getGroupCIReactions db g itemMemberId sharedMsgId
let ci' = CChatItem md ci {reactions}
r = ACIReaction SCTGroup SMDRcv (GroupChat g) $ CIReaction (CIGroupRcv m) ci' brokerTs reaction
pure $ Just $ CRChatItemReaction user r add
else pure Nothing
mapM_ toView cr_
reactionAllowed :: Bool -> MsgReaction -> [MsgReaction] -> Bool
reactionAllowed add reaction rs = (reaction `elem` rs) /= add && not (add && length rs >= maxMsgReactions)
catchCINotFound :: m a -> (SharedMsgId -> m a) -> m a
catchCINotFound f handle =
f `catchError` \case
ChatErrorStore (SEChatItemSharedMsgIdNotFound sharedMsgId) -> handle sharedMsgId
e -> throwError e
newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg@RcvMessage {sharedMsgId_} msgMeta = do
newGroupContentMessage gInfo m@GroupMember {localDisplayName = c, memberId} mc msg@RcvMessage {sharedMsgId_} msgMeta = do
-- TODO integrity message check
let (ExtMsgContent content fInv_ _ _) = mcExtMsgContent mc
if isVoice content && not (groupFeatureAllowed SGFVoice gInfo)
then void $ newChatItem (CIRcvGroupFeatureRejected GFVoice) Nothing Nothing False
@@ -3385,25 +3488,23 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
where
newChatItem ciContent ciFile_ timed_ live = do
ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live
groupMsgToView gInfo m ci msgMeta
reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_
groupMsgToView gInfo m ci {reactions} msgMeta
pure ci
groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m ()
groupMessageUpdate gInfo@GroupInfo {groupId, localDisplayName = g} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl_ live_ =
updateRcvChatItem `catchError` \e ->
case e of
(ChatErrorStore (SEChatItemSharedMsgIdNotFound _)) -> do
-- This patches initial sharedMsgId into chat item when locally deleted chat item
-- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete).
-- Chat item and update message which created it will have different sharedMsgId in this case...
let timed_ = rcvGroupCITimed gInfo ttl_
ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) msgMeta content Nothing timed_ live
ci' <- withStore' $ \db -> do
createChatItemVersion db (chatItemId' ci) brokerTs mc
updateGroupChatItem db user groupId ci content live Nothing
toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci')
setActive $ ActiveG g
_ -> throwError e
updateRcvChatItem `catchCINotFound` \_ -> do
-- This patches initial sharedMsgId into chat item when locally deleted chat item
-- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete).
-- Chat item and update message which created it will have different sharedMsgId in this case...
let timed_ = rcvGroupCITimed gInfo ttl_
ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) msgMeta content Nothing timed_ live
ci' <- withStore' $ \db -> do
createChatItemVersion db (chatItemId' ci) brokerTs mc
updateGroupChatItem db user groupId ci content live Nothing
toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci')
setActive $ ActiveG g
where
MsgMeta {broker = (_, brokerTs)} = msgMeta
content = CIRcvMsgContent mc
@@ -4050,7 +4151,7 @@ parseAChatMessage = parseChatMessage_
{-# INLINE parseAChatMessage #-}
parseChatMessage_ :: (ChatMonad m, StrEncoding s) => ByteString -> m s
parseChatMessage_ = liftEither . first (ChatError . CEInvalidChatMessage) . strDecode
parseChatMessage_ s = liftEither . first (ChatError . CEInvalidChatMessage (safeDecodeUtf8 s)) $ strDecode s
sendFileChunk :: ChatMonad m => User -> SndFileTransfer -> m ()
sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} =
@@ -4306,7 +4407,7 @@ mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs cur
let itemText = ciContentToText content
itemStatus = ciCreateStatus content
meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) tz currentTs itemTs currentTs currentTs
pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, file}
pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file}
deleteDirectCI :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> Bool -> Bool -> m ChatResponse
deleteDirectCI user ct ci@(CChatItem msgDir deletedItem@ChatItem {file}) byUser timed = do
@@ -4656,6 +4757,7 @@ chatCommandP =
"/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP),
"/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode),
"/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <* A.space <*> A.decimal <* A.space <*> A.decimal),
"/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> reactionP <* A.space <*> onOffP),
"/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))),
"/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP),
"/_delete " *> (APIDeleteChat <$> chatRefP),
@@ -4774,6 +4876,7 @@ chatCommandP =
("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> textP),
("\\\\ #" <|> "\\\\#") *> (DeleteMemberMessage <$> displayName <* A.space <* char_ '@' <*> displayName <* A.space <*> textP),
("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> msgTextP),
(("+" $> True) <|> ("-" $> False)) >>= \add -> reactionP <* A.space >>= \reaction -> ReactToMessage <$> chatNameP' <* A.space <*> textP <*> pure reaction <*> pure add,
"/feed " *> (SendMessageBroadcast <$> msgTextP),
("/chats" <|> "/cs") *> (LastChats <$> (" all" $> Nothing <|> Just <$> (A.space *> A.decimal <|> pure 20))),
("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> pure Nothing),
@@ -4844,6 +4947,18 @@ chatCommandP =
displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' '))
sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> msgTextP
quotedMsg = safeDecodeUtf8 <$> (A.char '(' *> A.takeTill (== ')') <* A.char ')') <* optional A.space
reactionP = MREmoji <$> (mrEmojiChar <$?> (toEmoji <$> A.anyChar))
toEmoji = \case
'1' -> '👍'
'+' -> '👍'
'-' -> '👎'
')' -> '😀'
'!' -> '🎉'
'?' -> '😕'
'*' -> head "❤️"
'^' -> '🚀'
'%' -> '👀'
c -> c
refChar c = c > ' ' && c /= '#' && c /= '@'
liveMessageP = " live=" *> onOffP <|> pure False
sendMessageTTLP = " ttl=" *> ((Just <$> A.decimal) <|> ("default" $> Nothing)) <|> pure Nothing
+5 -1
View File
@@ -104,6 +104,7 @@ data ChatConfig = ChatConfig
inlineFiles :: InlineFilesConfig,
xftpFileConfig :: Maybe XFTPFileConfig, -- Nothing - XFTP is disabled
tempDir :: Maybe FilePath,
showReactions :: Bool,
subscriptionEvents :: Bool,
hostEvents :: Bool,
logLevel :: ChatLogLevel,
@@ -218,6 +219,7 @@ data ChatCommand
| APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent}
| APIDeleteChatItem ChatRef ChatItemId CIDeleteMode
| APIDeleteMemberChatItem GroupId GroupMemberId ChatItemId
| APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, reaction :: MsgReaction, add :: Bool}
| APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId))
| APIChatUnread ChatRef Bool
| APIDeleteChat ChatRef
@@ -319,6 +321,7 @@ data ChatCommand
| DeleteMemberMessage GroupName ContactName Text
| EditMessage {chatName :: ChatName, editedMsg :: Text, message :: Text}
| UpdateLiveMessage {chatName :: ChatName, chatItemId :: ChatItemId, liveMessage :: Bool, message :: Text}
| ReactToMessage {chatName :: ChatName, reactToMessage :: Text, reaction :: MsgReaction, add :: Bool}
| APINewGroup UserId GroupProfile
| NewGroup GroupProfile
| AddMember GroupName ContactName GroupMemberRole
@@ -398,6 +401,7 @@ data ChatResponse
| CRChatItemStatusUpdated {user :: User, chatItem :: AChatItem}
| CRChatItemUpdated {user :: User, chatItem :: AChatItem}
| CRChatItemNotChanged {user :: User, chatItem :: AChatItem}
| CRChatItemReaction {user :: User, reaction :: ACIReaction, added :: Bool}
| CRChatItemDeleted {user :: User, deletedChatItem :: AChatItem, toChatItem :: Maybe AChatItem, byUser :: Bool, timed :: Bool}
| CRChatItemDeletedNotFound {user :: User, contact :: Contact, sharedMsgId :: SharedMsgId}
| CRBroadcastSent User MsgContent Int ZonedTime
@@ -771,7 +775,7 @@ data ChatErrorType
| CEChatNotStopped
| CEChatStoreChanged
| CEInvalidConnReq
| CEInvalidChatMessage {message :: String}
| CEInvalidChatMessage {messageData :: Text, message :: String}
| CEContactNotReady {contact :: Contact}
| CEContactDisabled {contact :: Contact}
| CEConnectionDisabled {connection :: Connection}
+45
View File
@@ -137,6 +137,7 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem
content :: CIContent d,
formattedText :: Maybe MarkdownList,
quotedItem :: Maybe (CIQuote c),
reactions :: [CIReactionCount],
file :: Maybe (CIFile d)
}
deriving (Show, Generic)
@@ -175,6 +176,11 @@ jsonCIDirection = \case
CIGroupSnd -> JCIGroupSnd
CIGroupRcv m -> JCIGroupRcv m
data CIReactionCount = CIReactionCount {reaction :: MsgReaction, userReacted :: Bool, totalReacted :: Int}
deriving (Show, Generic)
instance ToJSON CIReactionCount where toEncoding = J.genericToEncoding J.defaultOptions
data CChatItem c = forall d. MsgDirectionI d => CChatItem (SMsgDirection d) (ChatItem c d)
deriving instance Show (CChatItem c)
@@ -198,6 +204,11 @@ chatItemTs' ChatItem {meta = CIMeta {itemTs}} = itemTs
chatItemTimed :: ChatItem c d -> Maybe CITimed
chatItemTimed ChatItem {meta = CIMeta {itemTimed}} = itemTimed
chatItemMember :: GroupInfo -> ChatItem 'CTGroup d -> GroupMember
chatItemMember GroupInfo {membership} ChatItem {chatDir} = case chatDir of
CIGroupSnd -> membership
CIGroupRcv m -> m
data CIDeletedState = CIDeletedState
{ markedDeleted :: Bool,
deletedByMember :: Maybe GroupMember
@@ -388,6 +399,33 @@ instance ToJSON (CIQuote c) where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data CIReaction (c :: ChatType) (d :: MsgDirection) = CIReaction
{ chatDir :: CIDirection c d,
chatItem :: CChatItem c,
sentAt :: UTCTime,
reaction :: MsgReaction
}
deriving (Show, Generic)
instance ToJSON (CIReaction c d) where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data ACIReaction = forall c d. ACIReaction (SChatType c) (SMsgDirection d) (ChatInfo c) (CIReaction c d)
deriving instance Show ACIReaction
instance ToJSON ACIReaction where
toJSON (ACIReaction _ _ chat reaction) = J.toJSON $ JSONCIReaction chat reaction
toEncoding (ACIReaction _ _ chat reaction) = J.toEncoding $ JSONCIReaction chat reaction
data JSONCIReaction c d = JSONCIReaction {chatInfo :: ChatInfo c, chatReaction :: CIReaction c d}
deriving (Generic)
instance ToJSON (JSONCIReaction c d) where
toJSON = J.genericToJSON J.defaultOptions
toEncoding = J.genericToEncoding J.defaultOptions
data CIQDirection (c :: ChatType) where
CIQDirectSnd :: CIQDirection 'CTDirect
CIQDirectRcv :: CIQDirection 'CTDirect
@@ -766,6 +804,13 @@ instance ToJSON MsgDecryptError where
instance FromJSON MsgDecryptError where
parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MDE"
ciReactionAllowed :: ChatItem c d -> Bool
ciReactionAllowed ChatItem {meta = CIMeta {itemDeleted = Just _}} = False
ciReactionAllowed ChatItem {content} = case content of
CISndMsgContent _ -> True
CIRcvMsgContent _ -> True
_ -> False
ciRequiresAttention :: forall d. MsgDirectionI d => CIContent d -> Bool
ciRequiresAttention content = case msgDirection @d of
SMDSnd -> True
@@ -0,0 +1,47 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20230511_reactions where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20230511_reactions :: Query
m20230511_reactions =
[sql|
CREATE TABLE chat_item_reactions (
chat_item_reaction_id INTEGER PRIMARY KEY AUTOINCREMENT,
item_member_id BLOB, -- member that created item, NULL for items in direct chats
shared_msg_id BLOB NOT NULL,
contact_id INTEGER REFERENCES contacts ON DELETE CASCADE,
group_id INTEGER REFERENCES groups ON DELETE CASCADE,
group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, -- member that sent reaction, NULL for items in direct chats
created_by_msg_id INTEGER REFERENCES messages(message_id) ON DELETE SET NULL,
reaction TEXT NOT NULL, -- JSON of MsgReaction
reaction_sent INTEGER NOT NULL, -- 0 for received, 1 for sent
reaction_ts TEXT NOT NULL, -- broker_ts of creating message for received, created_at for sent
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE INDEX idx_chat_item_reactions_shared_msg_id ON chat_item_reactions(shared_msg_id);
CREATE INDEX idx_chat_item_reactions_contact_id ON chat_item_reactions(contact_id);
CREATE INDEX idx_chat_item_reactions_group_id ON chat_item_reactions(group_id);
CREATE INDEX idx_chat_item_reactions_group_member_id ON chat_item_reactions(group_member_id);
CREATE INDEX idx_chat_item_reactions_contact ON chat_item_reactions(contact_id, shared_msg_id);
CREATE INDEX idx_chat_item_reactions_group ON chat_item_reactions(group_id, shared_msg_id);
|]
down_m20230511_reactions :: Query
down_m20230511_reactions =
[sql|
DROP INDEX idx_chat_item_reactions_group;
DROP INDEX idx_chat_item_reactions_contact;
DROP INDEX idx_chat_item_reactions_group_member_id;
DROP INDEX idx_chat_item_reactions_group_id;
DROP INDEX idx_chat_item_reactions_contact_id;
DROP INDEX idx_chat_item_reactions_shared_msg_id;
DROP TABLE chat_item_reactions;
|]
@@ -463,6 +463,20 @@ CREATE TABLE chat_item_versions(
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE chat_item_reactions(
chat_item_reaction_id INTEGER PRIMARY KEY AUTOINCREMENT,
item_member_id BLOB, -- member that created item, NULL for items in direct chats
shared_msg_id BLOB NOT NULL,
contact_id INTEGER REFERENCES contacts ON DELETE CASCADE,
group_id INTEGER REFERENCES groups ON DELETE CASCADE,
group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, -- member that sent reaction, NULL for items in direct chats
created_by_msg_id INTEGER REFERENCES messages(message_id) ON DELETE SET NULL,
reaction TEXT NOT NULL, -- JSON of MsgReaction
reaction_sent INTEGER NOT NULL, -- 0 for received, 1 for sent
reaction_ts TEXT NOT NULL, -- broker_ts of creating message for received, created_at for sent
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE INDEX contact_profiles_index ON contact_profiles(
display_name,
full_name
@@ -607,3 +621,21 @@ CREATE INDEX idx_xftp_file_descriptions_user_id ON xftp_file_descriptions(
CREATE INDEX idx_chat_item_versions_chat_item_id ON chat_item_versions(
chat_item_id
);
CREATE INDEX idx_chat_item_reactions_shared_msg_id ON chat_item_reactions(
shared_msg_id
);
CREATE INDEX idx_chat_item_reactions_contact_id ON chat_item_reactions(
contact_id
);
CREATE INDEX idx_chat_item_reactions_group_id ON chat_item_reactions(group_id);
CREATE INDEX idx_chat_item_reactions_group_member_id ON chat_item_reactions(
group_member_id
);
CREATE INDEX idx_chat_item_reactions_contact ON chat_item_reactions(
contact_id,
shared_msg_id
);
CREATE INDEX idx_chat_item_reactions_group ON chat_item_reactions(
group_id,
shared_msg_id
);
+1
View File
@@ -128,6 +128,7 @@ mobileChatOpts dbFilePrefix dbKey =
chatCmdDelay = 3,
chatServerPort = Nothing,
optFilesFolder = Nothing,
showReactions = False,
allowInstantFiles = True,
muteNotifications = True,
maintenance = True
+7
View File
@@ -35,6 +35,7 @@ data ChatOpts = ChatOpts
chatCmdDelay :: Int,
chatServerPort :: Maybe String,
optFilesFolder :: Maybe FilePath,
showReactions :: Bool,
allowInstantFiles :: Bool,
muteNotifications :: Bool,
maintenance :: Bool
@@ -216,6 +217,11 @@ chatOptsP appDir defaultDbFileName = do
<> metavar "FOLDER"
<> help "Folder to use for sent and received files"
)
showReactions <-
switch
( long "reactions"
<> help "Show message reactions"
)
allowInstantFiles <-
switch
( long "allow-instant-files"
@@ -240,6 +246,7 @@ chatOptsP appDir defaultDbFileName = do
chatCmdDelay,
chatServerPort,
optFilesFolder,
showReactions,
allowInstantFiles,
muteNotifications,
maintenance
+38
View File
@@ -184,6 +184,7 @@ data ChatMsgEvent (e :: MsgEncoding) where
XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, ttl :: Maybe Int, live :: Maybe Bool} -> ChatMsgEvent 'Json
XMsgDel :: SharedMsgId -> Maybe MemberId -> ChatMsgEvent 'Json
XMsgDeleted :: ChatMsgEvent 'Json
XMsgReact :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, reaction :: MsgReaction, add :: Bool} -> ChatMsgEvent 'Json
XFile :: FileInvitation -> ChatMsgEvent 'Json -- TODO discontinue
XFileAcpt :: String -> ChatMsgEvent 'Json -- direct file protocol
XFileAcptInv :: SharedMsgId -> Maybe ConnReqInvitation -> String -> ChatMsgEvent 'Json
@@ -224,6 +225,37 @@ data AChatMsgEvent = forall e. MsgEncodingI e => ACME (SMsgEncoding e) (ChatMsgE
deriving instance Show AChatMsgEvent
data MsgReaction = MREmoji {emoji :: MREmojiChar}
deriving (Eq, Show, Generic)
instance ToJSON MsgReaction where
toEncoding = J.genericToEncoding . taggedObjectJSON $ dropPrefix "MR"
toJSON = J.genericToJSON . taggedObjectJSON $ dropPrefix "MR"
instance FromJSON MsgReaction where
parseJSON = J.genericParseJSON . taggedObjectJSON $ dropPrefix "MR"
instance ToField MsgReaction where
toField = toField . encodeJSON
instance FromField MsgReaction where
fromField = fromTextField_ decodeJSON
newtype MREmojiChar = MREmojiChar Char
deriving (Eq, Show)
instance ToJSON MREmojiChar where
toEncoding (MREmojiChar c) = J.toEncoding c
toJSON (MREmojiChar c) = J.toJSON c
instance FromJSON MREmojiChar where
parseJSON v = mrEmojiChar <$?> J.parseJSON v
mrEmojiChar :: Char -> Either String MREmojiChar
mrEmojiChar c
| c `elem` ("👍👎😀🎉😕❤️🚀👀" :: String) = Right $ MREmojiChar c
| otherwise = Left "bad emoji"
data FileChunk = FileChunk {chunkNo :: Integer, chunkBytes :: ByteString} | FileChunkCancel
deriving (Eq, Show)
@@ -473,6 +505,7 @@ data CMEventTag (e :: MsgEncoding) where
XMsgUpdate_ :: CMEventTag 'Json
XMsgDel_ :: CMEventTag 'Json
XMsgDeleted_ :: CMEventTag 'Json
XMsgReact_ :: CMEventTag 'Json
XFile_ :: CMEventTag 'Json
XFileAcpt_ :: CMEventTag 'Json
XFileAcptInv_ :: CMEventTag 'Json
@@ -517,6 +550,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where
XMsgUpdate_ -> "x.msg.update"
XMsgDel_ -> "x.msg.del"
XMsgDeleted_ -> "x.msg.deleted"
XMsgReact_ -> "x.msg.react"
XFile_ -> "x.file"
XFileAcpt_ -> "x.file.acpt"
XFileAcptInv_ -> "x.file.acpt.inv"
@@ -562,6 +596,7 @@ instance StrEncoding ACMEventTag where
"x.msg.update" -> XMsgUpdate_
"x.msg.del" -> XMsgDel_
"x.msg.deleted" -> XMsgDeleted_
"x.msg.react" -> XMsgReact_
"x.file" -> XFile_
"x.file.acpt" -> XFileAcpt_
"x.file.acpt.inv" -> XFileAcptInv_
@@ -603,6 +638,7 @@ toCMEventTag msg = case msg of
XMsgUpdate {} -> XMsgUpdate_
XMsgDel {} -> XMsgDel_
XMsgDeleted -> XMsgDeleted_
XMsgReact {} -> XMsgReact_
XFile _ -> XFile_
XFileAcpt _ -> XFileAcpt_
XFileAcptInv {} -> XFileAcptInv_
@@ -690,6 +726,7 @@ appJsonToCM AppMessageJson {msgId, event, params} = do
XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content" <*> opt "ttl" <*> opt "live"
XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId"
XMsgDeleted_ -> pure XMsgDeleted
XMsgReact_ -> XMsgReact <$> p "msgId" <*> opt "memberId" <*> p "reaction" <*> p "add"
XFile_ -> XFile <$> p "file"
XFileAcpt_ -> XFileAcpt <$> p "fileName"
XFileAcptInv_ -> XFileAcptInv <$> p "msgId" <*> opt "fileConnReq" <*> p "fileName"
@@ -745,6 +782,7 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = case encoding @e of
XMsgUpdate msgId' content ttl live -> o $ ("ttl" .=? ttl) $ ("live" .=? live) ["msgId" .= msgId', "content" .= content]
XMsgDel msgId' memberId -> o $ ("memberId" .=? memberId) ["msgId" .= msgId']
XMsgDeleted -> JM.empty
XMsgReact msgId' memberId reaction add -> o $ ("memberId" .=? memberId) ["msgId" .= msgId', "reaction" .= reaction, "add" .= add]
XFile fileInv -> o ["file" .= fileInv]
XFileAcpt fileName -> o ["fileName" .= fileName]
XFileAcptInv sharedMsgId fileConnReq fileName -> o $ ("fileConnReq" .=? fileConnReq) ["msgId" .= sharedMsgId, "fileName" .= fileName]
+181 -37
View File
@@ -228,6 +228,12 @@ module Simplex.Chat.Store
getAllChatItems,
getAChatItem,
getChatItemVersions,
getDirectCIReactions,
getDirectReactions,
setDirectReaction,
getGroupCIReactions,
getGroupReactions,
setGroupReaction,
getChatItemIdByAgentMsgId,
getDirectChatItem,
getDirectChatItemBySharedMsgId,
@@ -384,6 +390,7 @@ import Simplex.Chat.Migrations.M20230420_rcv_files_to_receive
import Simplex.Chat.Migrations.M20230422_profile_contact_links
import Simplex.Chat.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages
import Simplex.Chat.Migrations.M20230505_chat_item_versions
import Simplex.Chat.Migrations.M20230511_reactions
import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Chat.Util (week)
@@ -461,7 +468,8 @@ schemaMigrations =
("20230420_rcv_files_to_receive", m20230420_rcv_files_to_receive, Just down_m20230420_rcv_files_to_receive),
("20230422_profile_contact_links", m20230422_profile_contact_links, Just down_m20230422_profile_contact_links),
("20230504_recreate_msg_delivery_events_cleanup_messages", m20230504_recreate_msg_delivery_events_cleanup_messages, Just down_m20230504_recreate_msg_delivery_events_cleanup_messages),
("20230505_chat_item_versions", m20230505_chat_item_versions, Just down_m20230505_chat_item_versions)
("20230505_chat_item_versions", m20230505_chat_item_versions, Just down_m20230505_chat_item_versions),
("20230511_reactions", m20230511_reactions, Just down_m20230511_reactions)
]
-- | The list of migrations in ascending order by date
@@ -3435,6 +3443,7 @@ deleteContactCIs db user@User {userId} ct@Contact {contactId} = do
connIds <- getContactConnIds_ db user ct
forM_ connIds $ \connId ->
DB.execute db "DELETE FROM messages WHERE connection_id = ?" (Only connId)
DB.execute db "DELETE FROM chat_item_reactions WHERE contact_id = ?" (Only contactId)
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId)
getContactConnIds_ :: DB.Connection -> User -> Contact -> IO [Int64]
@@ -3450,6 +3459,7 @@ getGroupFileInfo db User {userId} GroupInfo {groupId} =
deleteGroupCIs :: DB.Connection -> User -> GroupInfo -> IO ()
deleteGroupCIs db User {userId} GroupInfo {groupId} = do
DB.execute db "DELETE FROM messages WHERE group_id = ?" (Only groupId)
DB.execute db "DELETE FROM chat_item_reactions WHERE group_id = ?" (Only groupId)
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId)
createNewSndMessage :: MsgEncodingI e => DB.Connection -> TVar ChaChaDRG -> ConnOrGroupId -> (SharedMsgId -> NewMessage e) -> ExceptT StoreError IO SndMessage
@@ -3993,17 +4003,17 @@ toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, viaUser
getDirectChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChat db user contactId pagination search_ = do
let search = fromMaybe "" search_
case pagination of
CPLast count -> getDirectChatLast_ db user contactId count search
CPAfter afterId count -> getDirectChatAfter_ db user contactId afterId count search
CPBefore beforeId count -> getDirectChatBefore_ db user contactId beforeId count search
ct <- getContact db user contactId
liftIO . getDirectChatReactions_ db ct =<< case pagination of
CPLast count -> getDirectChatLast_ db user ct count search
CPAfter afterId count -> getDirectChatAfter_ db user ct afterId count search
CPBefore beforeId count -> getDirectChatBefore_ db user ct beforeId count search
getDirectChatLast_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatLast_ db user contactId count search = do
contact <- getContact db user contactId
getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatLast_ db user ct@Contact {contactId} count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItems <- getDirectChatItemsLast db user contactId count search
pure $ Chat (DirectChat contact) (reverse chatItems) stats
pure $ Chat (DirectChat ct) (reverse chatItems) stats
-- the last items in reverse order (the last item in the conversation is the first in the returned list)
getDirectChatItemsLast :: DB.Connection -> User -> ContactId -> Int -> String -> ExceptT StoreError IO [CChatItem 'CTDirect]
@@ -4030,12 +4040,11 @@ getDirectChatItemsLast db User {userId} contactId count search = ExceptT $ do
|]
(userId, contactId, search, count)
getDirectChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAfter_ db user@User {userId} contactId afterChatItemId count search = do
contact <- getContact db user contactId
getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatAfter_ db User {userId} ct@Contact {contactId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItems <- ExceptT getDirectChatItemsAfter_
pure $ Chat (DirectChat contact) chatItems stats
pure $ Chat (DirectChat ct) chatItems stats
where
getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect])
getDirectChatItemsAfter_ = do
@@ -4062,12 +4071,11 @@ getDirectChatAfter_ db user@User {userId} contactId afterChatItemId count search
|]
(userId, contactId, search, afterChatItemId, count)
getDirectChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatBefore_ db user@User {userId} contactId beforeChatItemId count search = do
contact <- getContact db user contactId
getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect)
getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItems <- ExceptT getDirectChatItemsBefore_
pure $ Chat (DirectChat contact) (reverse chatItems) stats
pure $ Chat (DirectChat ct) (reverse chatItems) stats
where
getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect])
getDirectChatItemsBefore_ = do
@@ -4133,18 +4141,18 @@ getContact db user@User {userId} contactId =
getGroupChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChat db user groupId pagination search_ = do
let search = fromMaybe "" search_
case pagination of
CPLast count -> getGroupChatLast_ db user groupId count search
CPAfter afterId count -> getGroupChatAfter_ db user groupId afterId count search
CPBefore beforeId count -> getGroupChatBefore_ db user groupId beforeId count search
g <- getGroupInfo db user groupId
liftIO . getGroupChatReactions_ db g =<< case pagination of
CPLast count -> getGroupChatLast_ db user g count search
CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search
CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search
getGroupChatLast_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatLast_ db user@User {userId} groupId count search = do
groupInfo <- getGroupInfo db user groupId
getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
chatItemIds <- liftIO getGroupChatItemIdsLast_
chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds
pure $ Chat (GroupChat groupInfo) (reverse chatItems) stats
pure $ Chat (GroupChat g) (reverse chatItems) stats
where
getGroupChatItemIdsLast_ :: IO [ChatItemId]
getGroupChatItemIdsLast_ =
@@ -4176,14 +4184,13 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do
(userId, groupId, groupMemberId)
getGroupChatItem db user groupId chatItemId
getGroupChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatAfter_ db user@User {userId} groupId afterChatItemId count search = do
groupInfo <- getGroupInfo db user groupId
getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
afterChatItem <- getGroupChatItem db user groupId afterChatItemId
chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem)
chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds
pure $ Chat (GroupChat groupInfo) chatItems stats
pure $ Chat (GroupChat g) chatItems stats
where
getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId]
getGroupChatItemIdsAfter_ afterChatItemTs =
@@ -4200,14 +4207,13 @@ getGroupChatAfter_ db user@User {userId} groupId afterChatItemId count search =
|]
(userId, groupId, search, afterChatItemTs, afterChatItemTs, afterChatItemId, count)
getGroupChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatBefore_ db user@User {userId} groupId beforeChatItemId count search = do
groupInfo <- getGroupInfo db user groupId
getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup)
getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId count search = do
let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False}
beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId
chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem)
chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds
pure $ Chat (GroupChat groupInfo) (reverse chatItems) stats
pure $ Chat (GroupChat g) (reverse chatItems) stats
where
getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId]
getGroupChatItemIdsBefore_ beforeChatItemTs =
@@ -4286,7 +4292,7 @@ getAllChatItems db user@User {userId} pagination search_ = do
CPLast count -> liftIO $ getAllChatItemsLast_ count
CPAfter afterId count -> liftIO . getAllChatItemsAfter_ afterId count . aChatItemTs =<< getAChatItem db user afterId
CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem db user beforeId
mapM (uncurry $ getAChatItem_ db user) itemRefs
mapM (uncurry (getAChatItem_ db user) >=> liftIO . getACIReactions db) itemRefs
where
search = fromMaybe "" search_
getAllChatItemsLast_ count =
@@ -4438,6 +4444,7 @@ deleteDirectChatItem db User {userId} Contact {contactId} (CChatItem _ ci) = do
let itemId = chatItemId' ci
deleteChatItemMessages_ db itemId
deleteChatItemVersions_ db itemId
deleteDirectCIReactions_ db contactId ci
DB.execute
db
[sql|
@@ -4578,10 +4585,11 @@ updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ =
forM_ msgId_ $ \msgId -> insertChatItemMessage_ db itemId msgId updatedAt
deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> IO ()
deleteGroupChatItem db User {userId} GroupInfo {groupId} (CChatItem _ ci) = do
deleteGroupChatItem db User {userId} g@GroupInfo {groupId} (CChatItem _ ci) = do
let itemId = chatItemId' ci
deleteChatItemMessages_ db itemId
deleteChatItemVersions_ db itemId
deleteGroupCIReactions_ db g ci
DB.execute
db
[sql|
@@ -4833,6 +4841,140 @@ getChatItemVersions db itemId = do
toChatItemVersion :: (Int64, MsgContent, UTCTime, UTCTime) -> ChatItemVersion
toChatItemVersion (chatItemVersionId, msgContent, itemVersionTs, createdAt) = ChatItemVersion {chatItemVersionId, msgContent, itemVersionTs, createdAt}
getDirectChatReactions_ :: DB.Connection -> Contact -> Chat 'CTDirect -> IO (Chat 'CTDirect)
getDirectChatReactions_ db ct c@Chat {chatItems} = do
chatItems' <- forM chatItems $ \(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) -> do
reactions <- maybe (pure []) (getDirectCIReactions db ct) itemSharedMsgId
pure $ CChatItem md ci {reactions}
pure c {chatItems = chatItems'}
getGroupChatReactions_ :: DB.Connection -> GroupInfo -> Chat 'CTGroup -> IO (Chat 'CTGroup)
getGroupChatReactions_ db g c@Chat {chatItems} = do
chatItems' <- forM chatItems $ \(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) -> do
let GroupMember {memberId} = chatItemMember g ci
reactions <- maybe (pure []) (getGroupCIReactions db g memberId) itemSharedMsgId
pure $ CChatItem md ci {reactions}
pure c {chatItems = chatItems'}
getDirectCIReactions :: DB.Connection -> Contact -> SharedMsgId -> IO [CIReactionCount]
getDirectCIReactions db Contact {contactId} itemSharedMsgId =
map toCIReaction <$>
DB.query
db
[sql|
SELECT reaction, MAX(reaction_sent), COUNT(chat_item_reaction_id)
FROM chat_item_reactions
WHERE contact_id = ? AND shared_msg_id = ?
GROUP BY reaction
|]
(contactId, itemSharedMsgId)
getGroupCIReactions :: DB.Connection -> GroupInfo -> MemberId -> SharedMsgId -> IO [CIReactionCount]
getGroupCIReactions db GroupInfo {groupId} itemMemberId itemSharedMsgId =
map toCIReaction <$>
DB.query
db
[sql|
SELECT reaction, MAX(reaction_sent), COUNT(chat_item_reaction_id)
FROM chat_item_reactions
WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ?
GROUP BY reaction
|]
(groupId, itemMemberId, itemSharedMsgId)
getACIReactions :: DB.Connection -> AChatItem -> IO AChatItem
getACIReactions db aci@(AChatItem _ md chat ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of
Just itemSharedMId -> case chat of
DirectChat ct -> do
reactions <- getDirectCIReactions db ct itemSharedMId
pure $ AChatItem SCTDirect md chat ci {reactions}
GroupChat g -> do
let GroupMember {memberId} = chatItemMember g ci
reactions <- getGroupCIReactions db g memberId itemSharedMId
pure $ AChatItem SCTGroup md chat ci {reactions}
_ -> pure aci
_ -> pure aci
deleteDirectCIReactions_ :: DB.Connection -> ContactId -> ChatItem 'CTDirect d -> IO ()
deleteDirectCIReactions_ db contactId ChatItem {meta = CIMeta {itemSharedMsgId}} =
forM_ itemSharedMsgId $ \itemSharedMId ->
DB.execute db "DELETE FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ?" (contactId, itemSharedMId)
deleteGroupCIReactions_ :: DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> IO ()
deleteGroupCIReactions_ db g@GroupInfo {groupId} ci@ChatItem {meta = CIMeta {itemSharedMsgId}} =
forM_ itemSharedMsgId $ \itemSharedMId -> do
let GroupMember {memberId} = chatItemMember g ci
DB.execute db
"DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id = ?"
(groupId, itemSharedMId, memberId)
toCIReaction :: (MsgReaction, Bool, Int) -> CIReactionCount
toCIReaction (reaction, userReacted, totalReacted) = CIReactionCount {reaction, userReacted, totalReacted}
getDirectReactions :: DB.Connection -> Contact -> SharedMsgId -> Bool -> IO [MsgReaction]
getDirectReactions db ct itemSharedMId sent =
map fromOnly <$>
DB.query
db
[sql|
SELECT reaction
FROM chat_item_reactions
WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ?
|]
(contactId' ct, itemSharedMId, sent)
setDirectReaction :: DB.Connection -> Contact -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO ()
setDirectReaction db ct itemSharedMId sent reaction add msgId reactionTs
| add =
DB.execute
db
[sql|
INSERT INTO chat_item_reactions
(contact_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts)
VALUES (?,?,?,?,?,?)
|]
(contactId' ct, itemSharedMId, sent, reaction, msgId, reactionTs)
| otherwise =
DB.execute
db
[sql|
DELETE FROM chat_item_reactions
WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ?
|]
(contactId' ct, itemSharedMId, sent, reaction)
getGroupReactions :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> IO [MsgReaction]
getGroupReactions db GroupInfo {groupId} m itemMemberId itemSharedMId sent =
map fromOnly <$>
DB.query
db
[sql|
SELECT reaction
FROM chat_item_reactions
WHERE group_id = ? AND group_member_id = ? AND item_member_id = ? AND shared_msg_id = ? AND reaction_sent = ?
|]
(groupId, groupMemberId' m, itemMemberId, itemSharedMId, sent)
setGroupReaction :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO ()
setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reaction add msgId reactionTs
| add =
DB.execute
db
[sql|
INSERT INTO chat_item_reactions
(group_id, group_member_id, item_member_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts)
VALUES (?,?,?,?,?,?,?,?)
|]
(groupId, groupMemberId' m, itemMemberId, itemSharedMId, sent, reaction, msgId, reactionTs)
| otherwise =
DB.execute
db
[sql|
DELETE FROM chat_item_reactions
WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id = ? AND reaction_sent = ? AND reaction = ?
|]
(groupId, groupMemberId' m, itemSharedMId, itemMemberId, sent, reaction)
updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem
updateDirectCIFileStatus db user fileId fileStatus = do
aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db user fileId
@@ -5001,7 +5143,7 @@ toDirectChatItem tz currentTs (((itemId, itemTs, AMsgDirection msgDir, itemConte
_ -> Nothing
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect
cItem d chatDir ciStatus content file =
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, file}
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file}
badItem = Left $ SEBadChatItem itemId
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d
ciMeta content status =
@@ -5054,7 +5196,7 @@ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, AMsgDirection msgD
_ -> Nothing
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup
cItem d chatDir ciStatus content file =
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, file}
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file}
badItem = Left $ SEBadChatItem itemId
ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d
ciMeta content status =
@@ -5319,6 +5461,7 @@ deleteContactExpiredCIs db user@User {userId} ct@Contact {contactId} expirationD
connIds <- getContactConnIds_ db user ct
forM_ connIds $ \connId ->
DB.execute db "DELETE FROM messages WHERE connection_id = ? AND created_at <= ?" (connId, expirationDate)
DB.execute db "DELETE FROM chat_item_reactions WHERE contact_id = ? AND created_at <= ?" (contactId, expirationDate)
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? AND created_at <= ?" (userId, contactId, expirationDate)
getGroupExpiredFileInfo :: DB.Connection -> User -> GroupInfo -> UTCTime -> UTCTime -> IO [CIFileInfo]
@@ -5332,6 +5475,7 @@ getGroupExpiredFileInfo db User {userId} GroupInfo {groupId} expirationDate crea
deleteGroupExpiredCIs :: DB.Connection -> User -> GroupInfo -> UTCTime -> UTCTime -> IO ()
deleteGroupExpiredCIs db User {userId} GroupInfo {groupId} expirationDate createdAtCutoff = do
DB.execute db "DELETE FROM messages WHERE group_id = ? AND created_at <= ?" (groupId, min expirationDate createdAtCutoff)
DB.execute db "DELETE FROM chat_item_reactions WHERE group_id = ? AND reaction_ts <= ? AND created_at <= ?" (groupId, expirationDate, createdAtCutoff)
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ? AND item_ts <= ? AND created_at <= ?" (userId, groupId, expirationDate, createdAtCutoff)
-- | Saves unique local display name based on passed displayName, suffixed with _N if required.
+1 -1
View File
@@ -322,7 +322,7 @@ updateTermState user_ st ac live tw (key, ms) ts@TerminalState {inputString = s,
go _ _ = ""
charsWithContact cs
| live = cs
| null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" =
| null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" && cs /= "+" && cs /= "-" =
contactPrefix <> cs
| (s == ">" || s == "\\" || s == "!") && cs == " " =
cs <> contactPrefix
+2 -2
View File
@@ -122,8 +122,8 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d
liveItems <- readTVarIO showLiveItems
responseString cc liveItems r >>= printResp
where
markChatItemRead (AChatItem _ _ chat item@ChatItem {meta = CIMeta {itemStatus}}) =
case (muted chat item, itemStatus) of
markChatItemRead (AChatItem _ _ chat item@ChatItem {chatDir, meta = CIMeta {itemStatus}}) =
case (muted chat chatDir, itemStatus) of
(False, CISRcvNew) -> do
let itemId = chatItemId' item
chatRef = chatInfoToRef chat
+64 -8
View File
@@ -349,13 +349,15 @@ data ChatFeature
= CFTimedMessages
| CFFullDelete
| -- | CFReceipts
CFVoice
CFReactions
| CFVoice
| CFCalls
deriving (Show, Generic)
data SChatFeature (f :: ChatFeature) where
SCFTimedMessages :: SChatFeature 'CFTimedMessages
SCFFullDelete :: SChatFeature 'CFFullDelete
SCFReactions :: SChatFeature 'CFReactions
SCFVoice :: SChatFeature 'CFVoice
SCFCalls :: SChatFeature 'CFCalls
@@ -369,6 +371,7 @@ chatFeatureNameText :: ChatFeature -> Text
chatFeatureNameText = \case
CFTimedMessages -> "Disappearing messages"
CFFullDelete -> "Full deletion"
CFReactions -> "Message reactions"
CFVoice -> "Voice messages"
CFCalls -> "Audio/video calls"
@@ -391,7 +394,8 @@ allChatFeatures :: [AChatFeature]
allChatFeatures =
[ ACF SCFTimedMessages,
ACF SCFFullDelete,
-- CFReceipts,
-- ACF SCFReceipts,
ACF SCFReactions,
ACF SCFVoice,
ACF SCFCalls
]
@@ -400,7 +404,8 @@ chatPrefSel :: SChatFeature f -> Preferences -> Maybe (FeaturePreference f)
chatPrefSel = \case
SCFTimedMessages -> timedMessages
SCFFullDelete -> fullDelete
-- CFReceipts -> receipts
-- SCFReceipts -> receipts
SCFReactions -> reactions
SCFVoice -> voice
SCFCalls -> calls
@@ -408,6 +413,7 @@ chatFeature :: SChatFeature f -> ChatFeature
chatFeature = \case
SCFTimedMessages -> CFTimedMessages
SCFFullDelete -> CFFullDelete
SCFReactions -> CFReactions
SCFVoice -> CFVoice
SCFCalls -> CFCalls
@@ -425,6 +431,7 @@ instance PreferenceI FullPreferences where
SCFTimedMessages -> timedMessages
SCFFullDelete -> fullDelete
-- CFReceipts -> receipts
SCFReactions -> reactions
SCFVoice -> voice
SCFCalls -> calls
{-# INLINE getPreference #-}
@@ -445,6 +452,7 @@ setPreference_ f pref_ prefs =
case f of
SCFTimedMessages -> prefs {timedMessages = pref_}
SCFFullDelete -> prefs {fullDelete = pref_}
SCFReactions -> prefs {reactions = pref_}
SCFVoice -> prefs {voice = pref_}
SCFCalls -> prefs {calls = pref_}
@@ -453,6 +461,7 @@ data Preferences = Preferences
{ timedMessages :: Maybe TimedMessagesPreference,
fullDelete :: Maybe FullDeletePreference,
-- receipts :: Maybe SimplePreference,
reactions :: Maybe ReactionsPreference,
voice :: Maybe VoicePreference,
calls :: Maybe CallsPreference
}
@@ -473,14 +482,16 @@ data GroupFeature
| GFDirectMessages
| GFFullDelete
| -- | GFReceipts
GFVoice
GFReactions
| GFVoice
deriving (Show, Generic)
data SGroupFeature (f :: GroupFeature) where
SGFTimedMessages :: SGroupFeature 'GFTimedMessages
SGFDirectMessages :: SGroupFeature 'GFDirectMessages
SGFFullDelete :: SGroupFeature 'GFFullDelete
-- SGFReceipts
-- SGFReceipts :: SGroupFeature 'GFReceipts
SGFReactions :: SGroupFeature 'GFReactions
SGFVoice :: SGroupFeature 'GFVoice
deriving instance Show (SGroupFeature f)
@@ -494,6 +505,7 @@ groupFeatureNameText = \case
GFTimedMessages -> "Disappearing messages"
GFDirectMessages -> "Direct messages"
GFFullDelete -> "Full deletion"
GFReactions -> "Message reactions"
GFVoice -> "Voice messages"
groupFeatureNameText' :: SGroupFeature f -> Text
@@ -519,6 +531,7 @@ allGroupFeatures =
AGF SGFDirectMessages,
AGF SGFFullDelete,
-- GFReceipts,
AGF SGFReactions,
AGF SGFVoice
]
@@ -528,6 +541,7 @@ groupPrefSel = \case
SGFDirectMessages -> directMessages
SGFFullDelete -> fullDelete
-- GFReceipts -> receipts
SGFReactions -> reactions
SGFVoice -> voice
toGroupFeature :: SGroupFeature f -> GroupFeature
@@ -535,6 +549,7 @@ toGroupFeature = \case
SGFTimedMessages -> GFTimedMessages
SGFDirectMessages -> GFDirectMessages
SGFFullDelete -> GFFullDelete
SGFReactions -> GFReactions
SGFVoice -> GFVoice
class GroupPreferenceI p where
@@ -552,6 +567,7 @@ instance GroupPreferenceI FullGroupPreferences where
SGFDirectMessages -> directMessages
SGFFullDelete -> fullDelete
-- GFReceipts -> receipts
SGFReactions -> reactions
SGFVoice -> voice
{-# INLINE getGroupPreference #-}
@@ -561,6 +577,7 @@ data GroupPreferences = GroupPreferences
directMessages :: Maybe DirectMessagesGroupPreference,
fullDelete :: Maybe FullDeleteGroupPreference,
-- receipts :: Maybe GroupPreference,
reactions :: Maybe ReactionsGroupPreference,
voice :: Maybe VoiceGroupPreference
}
deriving (Eq, Show, Generic, FromJSON)
@@ -592,6 +609,7 @@ setGroupPreference_ f pref prefs =
toGroupPreferences $ case f of
SGFTimedMessages -> prefs {timedMessages = pref}
SGFDirectMessages -> prefs {directMessages = pref}
SGFReactions -> prefs {reactions = pref}
SGFVoice -> prefs {voice = pref}
SGFFullDelete -> prefs {fullDelete = pref}
@@ -607,6 +625,7 @@ data FullPreferences = FullPreferences
{ timedMessages :: TimedMessagesPreference,
fullDelete :: FullDeletePreference,
-- receipts :: SimplePreference,
reactions :: ReactionsPreference,
voice :: VoicePreference,
calls :: CallsPreference
}
@@ -621,6 +640,7 @@ data FullGroupPreferences = FullGroupPreferences
directMessages :: DirectMessagesGroupPreference,
fullDelete :: FullDeleteGroupPreference,
-- receipts :: GroupPreference,
reactions :: ReactionsGroupPreference,
voice :: VoiceGroupPreference
}
deriving (Eq, Show, Generic, FromJSON)
@@ -632,6 +652,7 @@ data ContactUserPreferences = ContactUserPreferences
{ timedMessages :: ContactUserPreference TimedMessagesPreference,
fullDelete :: ContactUserPreference FullDeletePreference,
-- receipts :: ContactUserPreference,
reactions :: ContactUserPreference ReactionsPreference,
voice :: ContactUserPreference VoicePreference,
calls :: ContactUserPreference CallsPreference
}
@@ -656,11 +677,12 @@ instance ToJSON p => ToJSON (ContactUserPref p) where
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CUP"
toChatPrefs :: FullPreferences -> Preferences
toChatPrefs FullPreferences {fullDelete, voice, timedMessages, calls} =
toChatPrefs FullPreferences {timedMessages, fullDelete, reactions, voice, calls} =
Preferences
{ timedMessages = Just timedMessages,
fullDelete = Just fullDelete,
-- receipts = Just receipts,
reactions = Just reactions,
voice = Just voice,
calls = Just calls
}
@@ -671,12 +693,13 @@ defaultChatPrefs =
{ timedMessages = TimedMessagesPreference {allow = FAYes, ttl = Nothing},
fullDelete = FullDeletePreference {allow = FANo},
-- receipts = SimplePreference {allow = FANo},
reactions = ReactionsPreference {allow = FAYes},
voice = VoicePreference {allow = FAYes},
calls = CallsPreference {allow = FAYes}
}
emptyChatPrefs :: Preferences
emptyChatPrefs = Preferences Nothing Nothing Nothing Nothing
emptyChatPrefs = Preferences Nothing Nothing Nothing Nothing Nothing
defaultGroupPrefs :: FullGroupPreferences
defaultGroupPrefs =
@@ -685,11 +708,12 @@ defaultGroupPrefs =
directMessages = DirectMessagesGroupPreference {enable = FEOff},
fullDelete = FullDeleteGroupPreference {enable = FEOff},
-- receipts = GroupPreference {enable = FEOff},
reactions = ReactionsGroupPreference {enable = FEOn},
voice = VoiceGroupPreference {enable = FEOn}
}
emptyGroupPrefs :: GroupPreferences
emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing
emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing
data TimedMessagesPreference = TimedMessagesPreference
{ allow :: FeatureAllowed,
@@ -706,6 +730,11 @@ data FullDeletePreference = FullDeletePreference {allow :: FeatureAllowed}
instance ToJSON FullDeletePreference where toEncoding = J.genericToEncoding J.defaultOptions
data ReactionsPreference = ReactionsPreference {allow :: FeatureAllowed}
deriving (Eq, Show, Generic, FromJSON)
instance ToJSON ReactionsPreference where toEncoding = J.genericToEncoding J.defaultOptions
data VoicePreference = VoicePreference {allow :: FeatureAllowed}
deriving (Eq, Show, Generic, FromJSON)
@@ -727,6 +756,9 @@ instance HasField "allow" TimedMessagesPreference FeatureAllowed where
instance HasField "allow" FullDeletePreference FeatureAllowed where
hasField p = (\allow -> p {allow}, allow (p :: FullDeletePreference))
instance HasField "allow" ReactionsPreference FeatureAllowed where
hasField p = (\allow -> p {allow}, allow (p :: ReactionsPreference))
instance HasField "allow" VoicePreference FeatureAllowed where
hasField p = (\allow -> p {allow}, allow (p :: VoicePreference))
@@ -743,6 +775,11 @@ instance FeatureI 'CFFullDelete where
sFeature = SCFFullDelete
prefParam _ = Nothing
instance FeatureI 'CFReactions where
type FeaturePreference 'CFReactions = ReactionsPreference
sFeature = SCFReactions
prefParam _ = Nothing
instance FeatureI 'CFVoice where
type FeaturePreference 'CFVoice = VoicePreference
sFeature = SCFVoice
@@ -771,6 +808,10 @@ data FullDeleteGroupPreference = FullDeleteGroupPreference
{enable :: GroupFeatureEnabled}
deriving (Eq, Show, Generic, FromJSON)
data ReactionsGroupPreference = ReactionsGroupPreference
{enable :: GroupFeatureEnabled}
deriving (Eq, Show, Generic, FromJSON)
data VoiceGroupPreference = VoiceGroupPreference
{enable :: GroupFeatureEnabled}
deriving (Eq, Show, Generic, FromJSON)
@@ -781,6 +822,8 @@ instance ToJSON TimedMessagesGroupPreference where toEncoding = J.genericToEncod
instance ToJSON DirectMessagesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions
instance ToJSON ReactionsGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions
instance ToJSON FullDeleteGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions
instance ToJSON VoiceGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions
@@ -799,6 +842,9 @@ instance HasField "enable" TimedMessagesGroupPreference GroupFeatureEnabled wher
instance HasField "enable" DirectMessagesGroupPreference GroupFeatureEnabled where
hasField p = (\enable -> p {enable}, enable (p :: DirectMessagesGroupPreference))
instance HasField "enable" ReactionsGroupPreference GroupFeatureEnabled where
hasField p = (\enable -> p {enable}, enable (p :: ReactionsGroupPreference))
instance HasField "enable" FullDeleteGroupPreference GroupFeatureEnabled where
hasField p = (\enable -> p {enable}, enable (p :: FullDeleteGroupPreference))
@@ -820,6 +866,11 @@ instance GroupFeatureI 'GFFullDelete where
sGroupFeature = SGFFullDelete
groupPrefParam _ = Nothing
instance GroupFeatureI 'GFReactions where
type GroupFeaturePreference 'GFReactions = ReactionsGroupPreference
sGroupFeature = SGFReactions
groupPrefParam _ = Nothing
instance GroupFeatureI 'GFVoice where
type GroupFeaturePreference 'GFVoice = VoiceGroupPreference
sGroupFeature = SGFVoice
@@ -930,6 +981,7 @@ mergePreferences contactPrefs userPreferences =
{ timedMessages = pref SCFTimedMessages,
fullDelete = pref SCFFullDelete,
-- receipts = pref CFReceipts,
reactions = pref SCFReactions,
voice = pref SCFVoice,
calls = pref SCFCalls
}
@@ -954,6 +1006,7 @@ mergeGroupPreferences groupPreferences =
directMessages = pref SGFDirectMessages,
fullDelete = pref SGFFullDelete,
-- receipts = pref GFReceipts,
reactions = pref SGFReactions,
voice = pref SGFVoice
}
where
@@ -967,6 +1020,7 @@ toGroupPreferences groupPreferences =
directMessages = pref SGFDirectMessages,
fullDelete = pref SGFFullDelete,
-- receipts = pref GFReceipts,
reactions = pref SGFReactions,
voice = pref SGFVoice
}
where
@@ -1044,6 +1098,7 @@ contactUserPreferences user userPreferences contactPreferences connectedIncognit
{ timedMessages = pref SCFTimedMessages,
fullDelete = pref SCFFullDelete,
-- receipts = pref CFReceipts,
reactions = pref SCFReactions,
voice = pref SCFVoice,
calls = pref SCFCalls
}
@@ -1071,6 +1126,7 @@ getContactUserPreference = \case
SCFTimedMessages -> timedMessages
SCFFullDelete -> fullDelete
-- CFReceipts -> receipts
SCFReactions -> reactions
SCFVoice -> voice
SCFCalls -> calls
+57 -9
View File
@@ -61,7 +61,7 @@ serializeChatResponse :: Maybe User -> CurrentTime -> TimeZone -> ChatResponse -
serializeChatResponse user_ ts tz = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz
responseToView :: Maybe User -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> ChatResponse -> [StyledString]
responseToView user_ ChatConfig {logLevel, testView} liveItems ts tz = \case
responseToView user_ ChatConfig {logLevel, showReactions, testView} liveItems ts tz = \case
CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile
CRUsersList users -> viewUsersList users
CRChatStarted -> ["chat started"]
@@ -83,14 +83,17 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts tz = \case
CRConnectionVerified u verified code -> ttyUser u [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code]
CRContactCode u ct code -> ttyUser u $ viewContactCode ct code testView
CRGroupMemberCode u g m code -> ttyUser u $ viewGroupMemberCode g m code testView
CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewChatItem chat item False ts
CRChatItems u chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems
CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewChatItem chat item False ts <> viewItemReactions item
CRChatItems u chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts <> viewItemReactions item) chatItems
CRChatItemInfo u ci ciInfo -> ttyUser u $ viewChatItemInfo ci ciInfo tz
CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId]
CRChatItemStatusUpdated u _ -> ttyUser u []
CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts
CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci
CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts testView
CRChatItemReaction u (ACIReaction _ _ chat reaction) added
| showReactions -> ttyUser u $ unmutedReaction chat reaction $ viewItemReaction chat reaction added ts tz
| otherwise -> []
CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"]
CRBroadcastSent u mc n t -> ttyUser u $ viewSentBroadcast mc n ts t
CRMsgIntegrityError u mErr -> ttyUser u $ viewMsgIntegrityError mErr
@@ -141,7 +144,7 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts tz = \case
CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"]
CRRcvFileDescrReady _ _ -> []
CRRcvFileDescrNotReady _ _ -> []
CRRcvFileProgressXFTP _ _ _ _ -> []
CRRcvFileProgressXFTP {} -> []
CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci
CRRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft
CRSndFileCancelled u _ ftm fts -> ttyUser u $ viewSndFileCancelled ftm fts
@@ -299,8 +302,12 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts tz = \case
contactList :: [ContactRef] -> String
contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs
unmuted :: ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString]
unmuted chat chatItem s
| muted chat chatItem = []
unmuted chat ChatItem {chatDir} = unmuted' chat chatDir
unmutedReaction :: ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString]
unmutedReaction chat CIReaction {chatDir} = unmuted' chat chatDir
unmuted' :: ChatInfo c -> CIDirection c d -> [StyledString] -> [StyledString]
unmuted' chat chatDir s
| muted chat chatDir = []
| otherwise = s
chatItemDeletedText :: ChatItem c d -> Maybe GroupMember -> Maybe Text
@@ -330,8 +337,8 @@ viewUsersList = mapMaybe userInfo . sortOn ldn
<> ["muted" | not showNtfs]
<> [plain ("unread: " <> show count) | count /= 0]
muted :: ChatInfo c -> ChatItem c d -> Bool
muted chat ChatItem {chatDir} = case (chat, chatDir) of
muted :: ChatInfo c -> CIDirection c d -> Bool
muted chat chatDir = case (chat, chatDir) of
(DirectChat Contact {chatSettings = DisableNtfs}, CIDirectRcv) -> True
(GroupChat GroupInfo {chatSettings = DisableNtfs}, CIGroupRcv _) -> True
_ -> False
@@ -504,6 +511,38 @@ viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} toItem by
Just (AChatItem _ _ _ ci) -> chatItemDeletedText ci $ chatInfoMembership chat
prohibited = [styled (colored Red) ("[unexpected message deletion, please report to developers]" :: String)]
viewItemReaction :: forall c d. ChatInfo c -> CIReaction c d -> Bool -> CurrentTime -> TimeZone -> [StyledString]
viewItemReaction chat CIReaction {chatDir, chatItem = CChatItem md ChatItem {chatDir = itemDir, content}, sentAt, reaction} added ts tz =
case (chat, chatDir) of
(DirectChat c, CIDirectRcv) -> case content of
CIRcvMsgContent mc -> view from $ reactionMsg mc
CISndMsgContent mc -> view from $ reactionMsg mc
_ -> []
where
from = ttyFromContact c
reactionMsg mc = quoteText mc $ if toMsgDirection md == MDSnd then ">>" else ">"
(GroupChat g, CIGroupRcv m) -> case content of
CIRcvMsgContent mc -> view from $ reactionMsg mc
CISndMsgContent mc -> view from $ reactionMsg mc
_ -> []
where
from = ttyFromGroup g m
reactionMsg mc = quoteText mc . ttyQuotedMember . Just $ sentByMember' g itemDir
(_, CIDirectSnd) -> [sentText]
(_, CIGroupSnd) -> [sentText]
where
view from msg = viewReceivedReaction from msg reactionText ts $ utcToZonedTime tz sentAt
reactionText = plain $ (if added then "+ " else "- ") <> [emoji]
MREmoji (MREmojiChar emoji) = reaction
sentText = plain $ (if added then "added " else "removed ") <> [emoji]
viewItemReactions :: ChatItem c d -> [StyledString]
viewItemReactions ChatItem {reactions} = [" " <> viewReactions reactions | not (null reactions)]
where
viewReactions = mconcat . intersperse " " . map viewReaction
viewReaction CIReactionCount {reaction = MREmoji (MREmojiChar emoji), userReacted, totalReacted} =
plain [emoji, ' '] <> (if userReacted then styled Italic else plain) (show totalReacted)
directQuote :: forall d'. MsgDirectionI d' => CIDirection 'CTDirect d' -> CIQuote 'CTDirect -> [StyledString]
directQuote _ CIQuote {content = qmc, chatDir = quoteDir} =
quoteText qmc $ if toMsgDirection (msgDirection @d') == quoteMsgDirection quoteDir then ">>" else ">"
@@ -516,6 +555,11 @@ sentByMember GroupInfo {membership} = \case
CIQGroupSnd -> Just membership
CIQGroupRcv m -> m
sentByMember' :: GroupInfo -> CIDirection 'CTGroup d -> GroupMember
sentByMember' GroupInfo {membership} = \case
CIGroupSnd -> membership
CIGroupRcv m -> m
quoteText :: MsgContent -> StyledString -> [StyledString]
quoteText qmc sentBy = prependFirst (sentBy <> " ") $ msgPreview qmc
@@ -1037,6 +1081,10 @@ viewReceivedUpdatedMessage = viewReceivedMessage_ True
viewReceivedMessage_ :: Bool -> StyledString -> [StyledString] -> MsgContent -> CurrentTime -> CIMeta c d -> [StyledString]
viewReceivedMessage_ updated from quote mc ts meta = receivedWithTime_ ts from quote meta (ttyMsgContent mc) updated
viewReceivedReaction :: StyledString -> [StyledString] -> StyledString -> CurrentTime -> ZonedTime -> [StyledString]
viewReceivedReaction from styledMsg reactionText ts reactionTs =
prependFirst (ttyMsgTime ts reactionTs <> " " <> from) (styledMsg <> [" " <> reactionText])
receivedWithTime_ :: CurrentTime -> StyledString -> [StyledString] -> CIMeta c d -> [StyledString] -> Bool -> [StyledString]
receivedWithTime_ ts from quote CIMeta {localItemTs, itemId, itemEdited, itemDeleted, itemLive} styledMsg updated = do
prependFirst (ttyMsgTime ts localItemTs <> " " <> from) (quote <> prependFirst (indent <> live) styledMsg)
@@ -1329,7 +1377,7 @@ viewChatError logLevel = \case
CEChatNotStopped -> ["error: chat not stopped"]
CEChatStoreChanged -> ["error: chat store changed, please restart chat"]
CEInvalidConnReq -> viewInvalidConnReq
CEInvalidChatMessage e -> ["chat message error: " <> sShow e]
CEInvalidChatMessage msg e -> [plain $ "chat message error: " <> e <> " (" <> T.unpack (T.take 120 msg) <> ")"]
CEContactNotReady c -> [ttyContact' c <> ": not ready"]
CEContactDisabled Contact {localDisplayName = c} -> [ttyContact c <> ": disabled, to enable: " <> highlight ("/enable " <> c) <> ", to delete: " <> highlight ("/d " <> c)]
CEConnectionDisabled Connection {connId, connType} -> [plain $ "connection " <> textEncode connType <> " (" <> tshow connId <> ") is disabled" | logLevel <= CLLWarning]
+1
View File
@@ -72,6 +72,7 @@ testOpts =
chatCmdDelay = 3,
chatServerPort = Nothing,
optFilesFolder = Nothing,
showReactions = True,
allowInstantFiles = True,
muteNotifications = True,
maintenance = False
+56 -4
View File
@@ -85,6 +85,8 @@ chatDirectTests = do
it "mark group member verified" testMarkGroupMemberVerified
describe "message errors" $ do
xit "show message decryption error and update count" testMsgDecryptError
describe "message reactions" $ do
it "set message reactions" testSetMessageReactions
testAddContact :: HasCallStack => SpecWith FilePath
testAddContact = versionTestMatrix2 runTestAddContact
@@ -421,13 +423,13 @@ testDirectLiveMessage =
connectUsers alice bob
-- non-empty live message is sent instantly
alice `send` "/live @bob hello"
bob <# "alice> [LIVE started] use /show [on/off/5] hello"
bob <# "alice> [LIVE started] use /show [on/off/6] hello"
alice ##> ("/_update item @2 " <> itemId 1 <> " text hello there")
alice <# "@bob [LIVE] hello there"
bob <# "alice> [LIVE ended] hello there"
-- empty live message is also sent instantly
alice `send` "/live @bob"
bob <# "alice> [LIVE started] use /show [on/off/6]"
bob <# "alice> [LIVE started] use /show [on/off/7]"
alice ##> ("/_update item @2 " <> itemId 2 <> " text hello 2")
alice <# "@bob [LIVE] hello 2"
bob <# "alice> [LIVE ended] hello 2"
@@ -1691,14 +1693,15 @@ testUserPrivacy =
alice <##? chatHistory
alice ##> "/_get items count=10"
alice <##? chatHistory
alice ##> "/_get items before=9 count=10"
alice ##> "/_get items before=11 count=10"
alice
<##? [ "bob> Disappearing messages: allowed",
"bob> Full deletion: off",
"bob> Message reactions: enabled",
"bob> Voice messages: enabled",
"bob> Audio/video calls: enabled"
]
alice ##> "/_get items after=8 count=10"
alice ##> "/_get items after=10 count=10"
alice
<##? [ "@bob hello",
"bob> hey",
@@ -1756,6 +1759,7 @@ testUserPrivacy =
chatHistory =
[ "bob> Disappearing messages: allowed",
"bob> Full deletion: off",
"bob> Message reactions: enabled",
"bob> Voice messages: enabled",
"bob> Audio/video calls: enabled",
"@bob hello",
@@ -1938,3 +1942,51 @@ testMsgDecryptError tmp =
copyDb from to = do
copyFile (chatStoreFile $ tmp </> from) (chatStoreFile $ tmp </> to)
copyFile (agentStoreFile $ tmp </> from) (agentStoreFile $ tmp </> to)
testSetMessageReactions :: HasCallStack => FilePath -> IO ()
testSetMessageReactions =
testChat2 aliceProfile bobProfile $
\alice bob -> do
connectUsers alice bob
alice #> "@bob hi"
bob <# "alice> hi"
bob ##> "+1 alice hi"
bob <## "added 👍"
alice <# "bob> >> hi"
alice <## " + 👍"
bob ##> "+1 alice hi"
bob <## "bad chat command: reaction already added"
bob ##> "+^ alice hi"
bob <## "added 🚀"
alice <# "bob> >> hi"
alice <## " + 🚀"
alice ##> "/tail @bob 1"
alice <# "@bob hi"
alice <## " 👍 1 🚀 1"
bob ##> "/tail @alice 1"
bob <# "alice> hi"
bob <## " 👍 1 🚀 1"
alice ##> "+1 bob hi"
alice <## "added 👍"
bob <# "alice> > hi"
bob <## " + 👍"
alice ##> "/tail @bob 1"
alice <# "@bob hi"
alice <## " 👍 2 🚀 1"
bob ##> "/tail @alice 1"
bob <# "alice> hi"
bob <## " 👍 2 🚀 1"
bob ##> "-1 alice hi"
bob <## "removed 👍"
alice <# "bob> >> hi"
alice <## " - 👍"
bob ##> "-^ alice hi"
bob <## "removed 🚀"
alice <# "bob> >> hi"
alice <## " - 🚀"
alice ##> "/tail @bob 1"
alice <# "@bob hi"
alice <## " 👍 1"
bob ##> "/tail @alice 1"
bob <# "alice> hi"
bob <## " 👍 1"
+72
View File
@@ -54,6 +54,8 @@ chatGroupTests = do
it "leaving and deleting the group joined via link should NOT delete previously existing direct contacts" testGroupLinkLeaveDelete
describe "group message errors" $ do
xit "show message decryption error and update count" testGroupMsgDecryptError
describe "message reactions" $ do
it "set group message reactions" testSetGroupMessageReactions
testGroup :: HasCallStack => SpecWith FilePath
testGroup = versionTestMatrix3 runTestGroup
@@ -1289,6 +1291,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile
alice <## "Disappearing messages: off"
alice <## "Direct messages: on"
alice <## "Full deletion: off"
alice <## "Message reactions: on"
alice <## "Voice messages: on"
bobAddedDan :: HasCallStack => TestCC -> IO ()
bobAddedDan cc = do
@@ -2155,3 +2158,72 @@ testGroupMsgDecryptError tmp =
copyDb from to = do
copyFile (chatStoreFile $ tmp </> from) (chatStoreFile $ tmp </> to)
copyFile (agentStoreFile $ tmp </> from) (agentStoreFile $ tmp </> to)
testSetGroupMessageReactions :: HasCallStack => FilePath -> IO ()
testSetGroupMessageReactions =
testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do
createGroup3 "team" alice bob cath
threadDelay 1000000
alice #> "#team hi"
bob <# "#team alice> hi"
cath <# "#team alice> hi"
bob ##> "+1 #team hi"
bob <## "added 👍"
alice <# "#team bob> > alice hi"
alice <## " + 👍"
cath <# "#team bob> > alice hi"
cath <## " + 👍"
bob ##> "+1 #team hi"
bob <## "bad chat command: reaction already added"
bob ##> "+^ #team hi"
bob <## "added 🚀"
alice <# "#team bob> > alice hi"
alice <## " + 🚀"
cath <# "#team bob> > alice hi"
cath <## " + 🚀"
alice ##> "/tail #team 1"
alice <# "#team hi"
alice <## " 👍 1 🚀 1"
bob ##> "/tail #team 1"
bob <# "#team alice> hi"
bob <## " 👍 1 🚀 1"
bob ##> "/tail #team 1"
bob <# "#team alice> hi"
bob <## " 👍 1 🚀 1"
alice ##> "+1 #team hi"
alice <## "added 👍"
bob <# "#team alice> > alice hi"
bob <## " + 👍"
cath <# "#team alice> > alice hi"
cath <## " + 👍"
alice ##> "/tail #team 1"
alice <# "#team hi"
alice <## " 👍 2 🚀 1"
bob ##> "/tail #team 1"
bob <# "#team alice> hi"
bob <## " 👍 2 🚀 1"
cath ##> "/tail #team 1"
cath <# "#team alice> hi"
cath <## " 👍 2 🚀 1"
bob ##> "-1 #team hi"
bob <## "removed 👍"
alice <# "#team bob> > alice hi"
alice <## " - 👍"
cath <# "#team bob> > alice hi"
cath <## " - 👍"
bob ##> "-^ #team hi"
bob <## "removed 🚀"
alice <# "#team bob> > alice hi"
alice <## " - 🚀"
cath <# "#team bob> > alice hi"
cath <## " - 🚀"
alice ##> "/tail #team 1"
alice <# "#team hi"
alice <## " 👍 1"
bob ##> "/tail #team 1"
bob <# "#team alice> hi"
bob <## " 👍 1"
cath ##> "/tail #team 1"
cath <# "#team alice> hi"
cath <## " 👍 1"
+1 -1
View File
@@ -1054,7 +1054,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $
alice ##> "/_set prefs @2 {}"
alice <## "your preferences for bob did not change"
(bob </)
let startFeatures = [(0, "Disappearing messages: allowed"), (0, "Full deletion: off"), (0, "Voice messages: off"), (0, "Audio/video calls: enabled")]
let startFeatures = [(0, "Disappearing messages: allowed"), (0, "Full deletion: off"), (0, "Message reactions: enabled"), (0, "Voice messages: off"), (0, "Audio/video calls: enabled")]
alice #$> ("/_get chat @2 count=100", chat, startFeatures)
bob #$> ("/_get chat @2 count=100", chat, startFeatures)
let sendVoice = "/_send @2 json {\"filePath\": \"test.txt\", \"msgContent\": {\"type\": \"voice\", \"text\": \"\", \"duration\": 10}}"
+14 -2
View File
@@ -183,7 +183,13 @@ chatFeaturesF :: [((Int, String), Maybe String)]
chatFeaturesF = map (\(a, _, c) -> (a, c)) chatFeatures''
chatFeatures'' :: [((Int, String), Maybe (Int, String), Maybe String)]
chatFeatures'' = [((0, "Disappearing messages: allowed"), Nothing, Nothing), ((0, "Full deletion: off"), Nothing, Nothing), ((0, "Voice messages: enabled"), Nothing, Nothing), ((0, "Audio/video calls: enabled"), Nothing, Nothing)]
chatFeatures'' =
[ ((0, "Disappearing messages: allowed"), Nothing, Nothing),
((0, "Full deletion: off"), Nothing, Nothing),
((0, "Message reactions: enabled"), Nothing, Nothing),
((0, "Voice messages: enabled"), Nothing, Nothing),
((0, "Audio/video calls: enabled"), Nothing, Nothing)
]
lastChatFeature :: String
lastChatFeature = snd $ last chatFeatures
@@ -192,7 +198,13 @@ groupFeatures :: [(Int, String)]
groupFeatures = map (\(a, _, _) -> a) groupFeatures''
groupFeatures'' :: [((Int, String), Maybe (Int, String), Maybe String)]
groupFeatures'' = [((0, "Disappearing messages: off"), Nothing, Nothing), ((0, "Direct messages: on"), Nothing, Nothing), ((0, "Full deletion: off"), Nothing, Nothing), ((0, "Voice messages: on"), Nothing, Nothing)]
groupFeatures'' =
[ ((0, "Disappearing messages: off"), Nothing, Nothing),
((0, "Direct messages: on"), Nothing, Nothing),
((0, "Full deletion: off"), Nothing, Nothing),
((0, "Message reactions: on"), Nothing, Nothing),
((0, "Voice messages: on"), Nothing, Nothing)
]
itemId :: Int -> String
itemId i = show $ length chatFeatures + i
+2 -2
View File
@@ -33,9 +33,9 @@ activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1
activeUser :: String
#if defined(darwin_HOST_OS) && defined(swiftJSON)
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true}}}}"
activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true}}}}"
#else
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true}}}"
activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true}}}"
#endif
chatStarted :: String
+14 -14
View File
@@ -86,10 +86,10 @@ s #==# msg = do
s ==# msg
testChatPreferences :: Maybe Preferences
testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing}
testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing, reactions = Just ReactionsPreference {allow = FAYes}}
testGroupPreferences :: Maybe GroupPreferences
testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, voice = Just VoiceGroupPreference {enable = FEOn}, fullDelete = Nothing}
testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn}, fullDelete = Nothing}
testProfile :: Profile
testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), contactLink = Nothing, preferences = testChatPreferences}
@@ -194,46 +194,46 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"event\":\"x.file.cancel\",\"params\":{\"msgId\":\"AQIDBA==\"}}"
#==# XFileCancel (SharedMsgId "\1\2\3\4")
it "x.info" $
"{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}"
"{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
#==# XInfo testProfile
it "x.info with empty full name" $
"{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}"
"{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
#==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing, contactLink = Nothing, preferences = testChatPreferences}
it "x.contact with xContactId" $
"{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}"
"{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
#==# XContact testProfile (Just $ XContactId "\1\2\3\4")
it "x.contact without XContactId" $
"{\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}"
"{\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
#==# XContact testProfile Nothing
it "x.contact with content null" $
"{\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}"
"{\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
==# XContact testProfile Nothing
it "x.contact with content (ignored)" $
"{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}"
"{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
==# XContact testProfile Nothing
it "x.grp.inv" $
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing}
it "x.grp.inv with group link id" $
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}"
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}"
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4"}
it "x.grp.acpt without incognito profile" $
"{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
#==# XGrpAcpt (MemberId "\1\2\3\4")
it "x.grp.mem.new" $
"{\"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\":{\"voice\":{\"allow\":\"yes\"}}}}}}"
"{\"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, profile = testProfile}
it "x.grp.mem.intro" $
"{\"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\":{\"voice\":{\"allow\":\"yes\"}}}}}}"
"{\"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, profile = testProfile}
it "x.grp.mem.inv" $
"{\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq}
it "x.grp.mem.fwd" $
"{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%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\":{\"voice\":{\"allow\":\"yes\"}}}}}}"
"{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%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, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq}
it "x.grp.mem.info" $
"{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}"
"{\"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\"}}}}}"
#==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile
it "x.grp.mem.con" $
"{\"event\":\"x.grp.mem.con\",\"params\":{\"memberId\":\"AQIDBA==\"}}"