mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Merge branch 'master' into master-ios
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -128,6 +128,7 @@ mobileChatOpts dbFilePrefix dbKey =
|
||||
chatCmdDelay = 3,
|
||||
chatServerPort = Nothing,
|
||||
optFilesFolder = Nothing,
|
||||
showReactions = False,
|
||||
allowInstantFiles = True,
|
||||
muteNotifications = True,
|
||||
maintenance = True
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -72,6 +72,7 @@ testOpts =
|
||||
chatCmdDelay = 3,
|
||||
chatServerPort = Nothing,
|
||||
optFilesFolder = Nothing,
|
||||
showReactions = True,
|
||||
allowInstantFiles = True,
|
||||
muteNotifications = True,
|
||||
maintenance = False
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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==\"}}"
|
||||
|
||||
Reference in New Issue
Block a user