ios: group member mentions (#5593)

* api types

* display for mentions and replys

* picking of mentions

* notifications (wip)

* auto tagging

* show selected mention

* Divider and list bg

* stop keyboard dismiss on scroll from ios 16

* change notification mode in all views

* icon for mentions notification mode

* make unread states work in memory and chat preview

* preview fixes

* fix unread status when mark read manually

* update library

* fixed padding

* fix layout

* use memberName

* remove ChatNtfs, show mentions in context items and in drafts, make mentions a map in ComposeState

* rework mentions (WIP)

* better

* show mention name containing @ in quotes

* editing mentions

* editing

* mentionColor

* opacity

* refactor mention counter

* fix unread layout

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Diogo 2025-02-03 20:47:32 +00:00 committed by GitHub
parent 760ea17fb9
commit 517679e2df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 664 additions and 147 deletions

View file

@ -499,7 +499,7 @@ final class ChatModel: ObservableObject {
[cItem]
}
if case .rcvNew = cItem.meta.itemStatus {
unreadCollector.changeUnreadCounter(cInfo.id, by: 1)
unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0)
}
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
} else {
@ -579,7 +579,7 @@ final class ChatModel: ObservableObject {
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if cItem.isRcvNew {
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0)
}
// update previews
if let chat = getChat(cInfo.id) {
@ -662,7 +662,7 @@ final class ChatModel: ObservableObject {
func markChatItemsRead(_ cInfo: ChatInfo) {
// update preview
_updateChat(cInfo.id) { chat in
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
self.decreaseUnreadCounter(user: self.currentUser!, chat: chat)
self.updateFloatingButtons(unreadCount: 0)
ChatTagsModel.shared.markChatTagRead(chat)
chat.chatStats = ChatStats()
@ -693,20 +693,28 @@ final class ChatModel: ObservableObject {
markCurrentChatRead(fromIndex: i)
_updateChat(cInfo.id) { chat in
var unreadBelow = 0
var unreadMentionsBelow = 0
var j = i - 1
while j >= 0 {
if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus {
let meta = self.im.reversedChatItems[j].meta
if case .rcvNew = meta.itemStatus {
unreadBelow += 1
if meta.userMention {
unreadMentionsBelow += 1
}
}
j -= 1
}
// update preview
let markedCount = chat.chatStats.unreadCount - unreadBelow
if markedCount > 0 {
let markedMentionsCount = chat.chatStats.unreadMentions - unreadMentionsBelow
if markedCount > 0 || markedMentionsCount > 0 {
let wasUnread = chat.unreadTag
chat.chatStats.unreadCount -= markedCount
chat.chatStats.unreadMentions -= markedMentionsCount
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions ? markedMentionsCount : markedCount
self.decreaseUnreadCounter(user: self.currentUser!, by: by)
self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
}
}
@ -727,7 +735,7 @@ final class ChatModel: ObservableObject {
func clearChat(_ cInfo: ChatInfo) {
// clear preview
if let chat = getChat(cInfo.id) {
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
self.decreaseUnreadCounter(user: self.currentUser!, chat: chat)
chat.chatItems = []
ChatTagsModel.shared.markChatTagRead(chat)
chat.chatStats = ChatStats()
@ -740,7 +748,7 @@ final class ChatModel: ObservableObject {
}
}
func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) {
func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) {
if self.chatId == cInfo.id {
for itemId in itemIds {
if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) {
@ -748,7 +756,7 @@ final class ChatModel: ObservableObject {
}
}
}
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count)
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead)
}
private let unreadCollector = UnreadCollector()
@ -756,16 +764,16 @@ final class ChatModel: ObservableObject {
class UnreadCollector {
private let subject = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
private var unreadCounts: [ChatId: Int] = [:]
private var unreadCounts: [ChatId: (unread: Int, mentions: Int)] = [:]
init() {
subject
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink {
let m = ChatModel.shared
for (chatId, count) in self.unreadCounts {
if let i = m.getChatIndex(chatId) {
m.changeUnreadCounter(i, by: count)
for (chatId, (unread, mentions)) in self.unreadCounts {
if unread != 0 || mentions != 0, let i = m.getChatIndex(chatId) {
m.changeUnreadCounter(i, by: unread, unreadMentions: mentions)
}
}
self.unreadCounts = [:]
@ -773,11 +781,12 @@ final class ChatModel: ObservableObject {
.store(in: &bag)
}
func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
func changeUnreadCounter(_ chatId: ChatId, by count: Int, unreadMentions: Int) {
if chatId == ChatModel.shared.chatId {
ChatView.FloatingButtonModel.shared.totalUnread += count
}
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
let (unread, mentions) = self.unreadCounts[chatId] ?? (0, 0)
self.unreadCounts[chatId] = (unread + count, mentions + unreadMentions)
subject.send()
}
}
@ -855,9 +864,11 @@ final class ChatModel: ObservableObject {
}
}
func changeUnreadCounter(_ chatIndex: Int, by count: Int) {
func changeUnreadCounter(_ chatIndex: Int, by count: Int, unreadMentions: Int) {
let wasUnread = chats[chatIndex].unreadTag
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count
let stats = chats[chatIndex].chatStats
chats[chatIndex].chatStats.unreadCount = stats.unreadCount + count
chats[chatIndex].chatStats.unreadMentions = stats.unreadMentions + unreadMentions
ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread)
changeUnreadCounter(user: currentUser!, by: count)
}
@ -866,6 +877,13 @@ final class ChatModel: ObservableObject {
changeUnreadCounter(user: user, by: 1)
}
func decreaseUnreadCounter(user: any UserLike, chat: Chat) {
let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions
? chat.chatStats.unreadMentions
: chat.chatStats.unreadCount
decreaseUnreadCounter(user: user, by: by)
}
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
changeUnreadCounter(user: user, by: -by)
}
@ -878,8 +896,20 @@ final class ChatModel: ObservableObject {
}
func totalUnreadCountForAllUsers() -> Int {
chats.filter { $0.chatInfo.ntfsEnabled }.reduce(0, { count, chat in count + chat.chatStats.unreadCount }) +
users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
var unread: Int = 0
for chat in chats {
switch chat.chatInfo.chatSettings?.enableNtfs {
case .all: unread += chat.chatStats.unreadCount
case .mentions: unread += chat.chatStats.unreadMentions
default: ()
}
}
for u in users {
if !u.user.activeUser {
unread += u.unreadCount
}
}
return unread
}
func increaseGroupReportsCounter(_ chatId: ChatId) {
@ -1104,7 +1134,11 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
}
var unreadTag: Bool {
chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat)
switch chatInfo.chatSettings?.enableNtfs {
case .all: chatStats.unreadChat || chatStats.unreadCount > 0
case .mentions: chatStats.unreadChat || chatStats.unreadMentions > 0
default: chatStats.unreadChat
}
}
var id: ChatId { get { chatInfo.id } }

View file

@ -248,7 +248,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
logger.debug("NtfManager.notifyMessageReceived")
if cInfo.ntfsEnabled {
if cInfo.ntfsEnabled(chatItem: cItem) {
addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0))
}
}

View file

@ -485,8 +485,8 @@ private func createChatItemsErrorAlert(_ r: ChatResponse) {
)
}
func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem {
let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay)
func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem {
let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay)
if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem }
throw r
}
@ -1491,11 +1491,11 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
}
}
func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) async {
func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async {
do {
try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
DispatchQueue.main.async {
ChatModel.shared.markChatItemsRead(cInfo, itemIds)
ChatModel.shared.markChatItemsRead(cInfo, itemIds, mentionsRead)
}
} catch {
logger.error("apiChatItemsRead error: \(responseError(error))")
@ -1576,6 +1576,7 @@ func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo {
throw r
}
// use ChatModel's loadGroupMembers from views
func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
let r = await chatSendCmd(.apiListMembers(groupId: groupId))
if case let .groupMembers(_, group) = r { return group.members }
@ -2027,7 +2028,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
if cItem.isActiveReport {
m.increaseGroupReportsCounter(cInfo.id)
}
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
} else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) {
m.increaseUnreadCounter(user: user)
}
}
@ -2072,7 +2073,8 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .chatItemsDeleted(user, items, _):
if !active(user) {
for item in items {
if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled {
let d = item.deletedChatItem
if item.toChatItem == nil && d.chatItem.isRcvNew && d.chatInfo.ntfsEnabled(chatItem: d.chatItem) {
await MainActor.run {
m.decreaseUnreadCounter(user: user)
}

View file

@ -158,7 +158,9 @@ struct ChatInfoView: View {
searchButton(width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
muteButton(width: buttonWidth)
if let nextNtfMode = chat.chatInfo.nextNtfMode {
muteButton(width: buttonWidth, nextNtfMode: nextNtfMode)
}
}
}
.padding(.trailing)
@ -432,13 +434,13 @@ struct ChatInfoView: View {
.disabled(!contact.ready || chat.chatItems.isEmpty)
}
private func muteButton(width: CGFloat) -> some View {
InfoViewButton(
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
return InfoViewButton(
image: nextNtfMode.iconFilled,
title: "\(nextNtfMode.text(mentions: false))",
width: width
) {
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
toggleNotifications(chat, enableNtfs: nextNtfMode)
}
.disabled(!contact.ready || !contact.active)
}

View file

@ -254,7 +254,7 @@ struct FramedItemView: View {
VStack(alignment: .leading, spacing: 2) {
Text(sender)
.font(.caption)
.foregroundColor(theme.colors.secondary)
.foregroundColor(qi.chatDir == .groupSnd ? .accentColor : theme.colors.secondary)
.lineLimit(1)
ciQuotedMsgTextView(qi, lines: 2)
}
@ -302,6 +302,8 @@ struct FramedItemView: View {
text: text,
formattedText: ft,
meta: ci.meta,
mentions: ci.mentions,
userMemberId: chat.chatInfo.groupInfo?.membership.memberId,
rightToLeft: rtl,
showSecrets: showSecrets,
prefix: txtPrefix

View file

@ -32,6 +32,8 @@ struct MsgContentView: View {
var formattedText: [FormattedText]? = nil
var sender: String? = nil
var meta: CIMeta? = nil
var mentions: [String: CIMention]? = nil
var userMemberId: String? = nil
var rightToLeft = false
var showSecrets: Bool
var prefix: Text? = nil
@ -68,7 +70,7 @@ struct MsgContentView: View {
}
private func msgContentView() -> Text {
var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix)
var v = messageText(text, formattedText, sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix)
if let mt = meta {
if mt.isLive {
v = v + typingIndicator(mt.recent)
@ -90,15 +92,15 @@ struct MsgContentView: View {
}
}
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text {
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, mentions: [String: CIMention]?, userMemberId: String?, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text {
let s = text
var res: Text
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
res = formatText(ft[0], preview, showSecret: showSecrets)
res = formatText(ft[0], preview, showSecret: showSecrets, mentions: mentions, userMemberId: userMemberId)
var i = 1
while i < ft.count {
res = res + formatText(ft[i], preview, showSecret: showSecrets)
res = res + formatText(ft[i], preview, showSecret: showSecrets, mentions: mentions, userMemberId: userMemberId)
i = i + 1
}
} else {
@ -121,7 +123,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
}
}
private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) -> Text {
private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool, mentions: [String: CIMention]?, userMemberId: String?) -> Text {
let t = ft.text
if let f = ft.format {
switch (f) {
@ -144,6 +146,21 @@ private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool)
case .full: return linkText(t, simplexUri, preview, prefix: "")
case .browser: return linkText(t, simplexUri, preview, prefix: "")
}
case let .mention(memberName):
if let m = mentions?[memberName] {
if let ref = m.memberRef {
let name: String = if let alias = ref.localAlias, alias != "" {
"\(alias) (\(ref.displayName))"
} else {
ref.displayName
}
let tName = mentionText(name)
return m.memberId == userMemberId ? tName.foregroundColor(.accentColor) : tName
} else {
return mentionText(memberName)
}
}
return Text(t)
case .email: return linkText(t, t, preview, prefix: "mailto:")
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
}
@ -152,6 +169,10 @@ private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool)
}
}
private func mentionText(_ name: String) -> Text {
Text(name.contains(" @") ? "@'\(name)'" : "@\(name)").fontWeight(.semibold)
}
private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text {
preview
? Text(s).foregroundColor(color).underline(color: color)

View file

@ -14,6 +14,7 @@ struct ChatItemInfoView: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
var ci: ChatItem
var userMemberId: String?
@Binding var chatItemInfo: ChatItemInfo?
@State private var selection: CIInfoTab = .history
@State private var alert: CIInfoViewAlert? = nil
@ -258,7 +259,7 @@ struct ChatItemInfoView: View {
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View {
if text != "" {
TextBubble(text: text, formattedText: formattedText, sender: sender)
TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId)
} else {
Text("no text")
.italic()
@ -271,10 +272,12 @@ struct ChatItemInfoView: View {
var text: String
var formattedText: [FormattedText]?
var sender: String? = nil
var mentions: [String: CIMention]?
var userMemberId: String?
@State private var showSecrets = false
var body: some View {
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary))
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, secondaryColor: theme.colors.secondary))
}
}
@ -548,6 +551,6 @@ func localTimestamp(_ date: Date) -> String {
struct ChatItemInfoView_Previews: PreviewProvider {
static var previews: some View {
ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), chatItemInfo: Binding.constant(nil))
ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), userMemberId: Chat.sampleData.chatInfo.groupInfo?.membership.memberId, chatItemInfo: Binding.constant(nil))
}
}

View file

@ -26,6 +26,7 @@ struct ChatView: View {
@State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState()
@State private var selectedRange = NSRange()
@State private var keyboardVisible = false
@State private var connectionStats: ConnectionStats?
@State private var customUserProfile: Profile?
@ -76,6 +77,9 @@ struct ChatView: View {
VStack(spacing: 0) {
ZStack(alignment: .bottomTrailing) {
chatItemsList()
if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty {
GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible)
}
FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat)
}
connectingText()
@ -83,7 +87,8 @@ struct ChatView: View {
ComposeView(
chat: chat,
composeState: $composeState,
keyboardVisible: $keyboardVisible
keyboardVisible: $keyboardVisible,
selectedRange: $selectedRange
)
.disabled(!cInfo.sendMsgEnabled)
} else {
@ -991,31 +996,37 @@ struct ChatView: View {
markedRead = true
}
if let range {
let itemIds = unreadItemIds(range)
let (itemIds, unreadMentions) = unreadItemIds(range)
if !itemIds.isEmpty {
waitToMarkRead {
await apiMarkChatItemsRead(chat.chatInfo, itemIds)
await apiMarkChatItemsRead(chat.chatInfo, itemIds, mentionsRead: unreadMentions)
}
}
} else if chatItem.isRcvNew {
waitToMarkRead {
await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id])
await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0)
}
}
}
.actionSheet(item: $actionSheet) { $0.actionSheet }
}
private func unreadItemIds(_ range: ClosedRange<Int>) -> [ChatItem.ID] {
private func unreadItemIds(_ range: ClosedRange<Int>) -> ([ChatItem.ID], Int) {
let im = ItemsModel.shared
return range.compactMap { i in
if i >= 0 && i < im.reversedChatItems.count {
let ci = im.reversedChatItems[i]
return if ci.isRcvNew { ci.id } else { nil }
} else {
return nil
var unreadItems: [ChatItem.ID] = []
var unreadMentions: Int = 0
for i in range {
let ci = im.reversedChatItems[i]
if ci.isRcvNew {
unreadItems.append(ci.id)
if ci.meta.userMention {
unreadMentions += 1
}
}
}
return (unreadItems, unreadMentions)
}
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
@ -1227,7 +1238,7 @@ struct ChatView: View {
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
chatItemInfo = nil
}) {
ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo)
ChatItemInfoView(ci: ci, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, chatItemInfo: $chatItemInfo)
}
}
@ -2044,21 +2055,19 @@ struct ToggleNtfsButton: View {
@ObservedObject var chat: Chat
var body: some View {
Button {
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
} label: {
if chat.chatInfo.ntfsEnabled {
Label("Mute", systemImage: "speaker.slash")
} else {
Label("Unmute", systemImage: "speaker.wave.2")
if let nextMode = chat.chatInfo.nextNtfMode {
Button {
toggleNotifications(chat, enableNtfs: nextMode)
} label: {
Label(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.icon)
}
}
}
}
func toggleNotifications(_ chat: Chat, enableNtfs: Bool) {
func toggleNotifications(_ chat: Chat, enableNtfs: MsgFilter) {
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
chatSettings.enableNtfs = enableNtfs ? .all : .none
chatSettings.enableNtfs = enableNtfs
updateChatSettings(chat, chatSettings: chatSettings)
}

View file

@ -11,6 +11,8 @@ import SimpleXChat
import SwiftyGif
import PhotosUI
let MAX_NUMBER_OF_MENTIONS = 3
enum ComposePreview {
case noPreview
case linkPreview(linkPreview: LinkPreview?)
@ -19,7 +21,7 @@ enum ComposePreview {
case filePreview(fileName: String, file: URL)
}
enum ComposeContextItem {
enum ComposeContextItem: Equatable {
case noContextItem
case quotedItem(chatItem: ChatItem)
case editingItem(chatItem: ChatItem)
@ -39,31 +41,41 @@ struct LiveMessage {
var sentMsg: String?
}
typealias MentionedMembers = [String: CIMention]
struct ComposeState {
var message: String
var parsedMessage: [FormattedText]
var liveMessage: LiveMessage? = nil
var preview: ComposePreview
var contextItem: ComposeContextItem
var voiceMessageRecordingState: VoiceMessageRecordingState
var inProgress = false
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
var mentions: MentionedMembers = [:]
init(
message: String = "",
parsedMessage: [FormattedText] = [],
liveMessage: LiveMessage? = nil,
preview: ComposePreview = .noPreview,
contextItem: ComposeContextItem = .noContextItem,
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording,
mentions: MentionedMembers = [:]
) {
self.message = message
self.parsedMessage = parsedMessage
self.liveMessage = liveMessage
self.preview = preview
self.contextItem = contextItem
self.voiceMessageRecordingState = voiceMessageRecordingState
self.mentions = mentions
}
init(editingItem: ChatItem) {
self.message = editingItem.content.text
let text = editingItem.content.text
self.message = text
self.parsedMessage = editingItem.formattedText ?? FormattedText.plain(text)
self.preview = chatItemPreview(chatItem: editingItem)
self.contextItem = .editingItem(chatItem: editingItem)
if let emc = editingItem.content.msgContent,
@ -72,10 +84,12 @@ struct ComposeState {
} else {
self.voiceMessageRecordingState = .noRecording
}
self.mentions = editingItem.mentions ?? [:]
}
init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) {
self.message = ""
self.parsedMessage = []
self.preview = .noPreview
self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo)
self.voiceMessageRecordingState = .noRecording
@ -83,20 +97,38 @@ struct ComposeState {
func copy(
message: String? = nil,
parsedMessage: [FormattedText]? = nil,
liveMessage: LiveMessage? = nil,
preview: ComposePreview? = nil,
contextItem: ComposeContextItem? = nil,
voiceMessageRecordingState: VoiceMessageRecordingState? = nil
voiceMessageRecordingState: VoiceMessageRecordingState? = nil,
mentions: MentionedMembers? = nil
) -> ComposeState {
ComposeState(
message: message ?? self.message,
parsedMessage: parsedMessage ?? self.parsedMessage,
liveMessage: liveMessage ?? self.liveMessage,
preview: preview ?? self.preview,
contextItem: contextItem ?? self.contextItem,
voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState
voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState,
mentions: mentions ?? self.mentions
)
}
func mentionMemberName(_ name: String) -> String {
var n = 0
var tryName = name
while mentions[tryName] != nil {
n += 1
tryName = "\(name)_\(n)"
}
return tryName
}
var memberMentions: [String: Int64] {
self.mentions.compactMapValues { $0.memberRef?.groupMemberId }
}
var editing: Bool {
switch contextItem {
case .editingItem: return true
@ -293,6 +325,7 @@ struct ComposeView: View {
@ObservedObject var chat: Chat
@Binding var composeState: ComposeState
@Binding var keyboardVisible: Bool
@Binding var selectedRange: NSRange
@State var linkUrl: URL? = nil
@State var hasSimplexLink: Bool = false
@ -376,6 +409,7 @@ struct ComposeView: View {
ZStack(alignment: .leading) {
SendMessageView(
composeState: $composeState,
selectedRange: $selectedRange,
sendMessage: { ttl in
sendMessage(ttl: ttl)
resetLinkPreview()
@ -428,15 +462,17 @@ struct ComposeView: View {
.ignoresSafeArea(.all, edges: .bottom)
}
.onChange(of: composeState.message) { msg in
let parsedMsg = parseSimpleXMarkdown(msg)
composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg))
if composeState.linkPreviewAllowed {
if msg.count > 0 {
showLinkPreview(msg)
showLinkPreview(parsedMsg)
} else {
resetLinkPreview()
hasSimplexLink = false
}
} else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) {
(_, hasSimplexLink) = parseMessage(msg)
(_, hasSimplexLink) = getSimplexLink(parsedMsg)
} else {
hasSimplexLink = false
}
@ -793,6 +829,7 @@ struct ComposeView: View {
var sent: ChatItem?
let msgText = text ?? composeState.message
let liveMessage = composeState.liveMessage
let mentions = composeState.memberMentions
if !live {
if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) }
await sending()
@ -803,7 +840,7 @@ struct ComposeView: View {
// Composed text is send as a reply to the last forwarded item
sent = await forwardItems(chatItems, fromChatInfo, ttl).last
if !composeState.message.isEmpty {
_ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl)
_ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl, mentions: mentions)
}
} else if case let .editingItem(ci) = composeState.contextItem {
sent = await updateMessage(ci, live: live)
@ -819,10 +856,11 @@ struct ComposeView: View {
switch (composeState.preview) {
case .noPreview:
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
case let .mediaPreviews(media):
// TODO: CHECK THIS
let last = media.count - 1
var msgs: [ComposedMessage] = []
if last >= 0 {
@ -847,10 +885,10 @@ struct ComposeView: View {
case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle()
let file = voiceCryptoFile(recordingFileName)
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl)
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl, mentions: mentions)
case let .filePreview(_, file):
if let savedFile = saveFileFromURL(file) {
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl, mentions: mentions)
}
}
}
@ -905,7 +943,7 @@ struct ComposeView: View {
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: ei.id,
msg: mc,
updatedMessage: UpdatedMessage(msgContent: mc, mentions: composeState.memberMentions),
live: live
)
await MainActor.run {
@ -977,9 +1015,9 @@ struct ComposeView: View {
return nil
}
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64]) async -> ChatItem? {
await send(
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)],
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)],
live: live,
ttl: ttl
).first
@ -1043,7 +1081,8 @@ struct ComposeView: View {
func checkLinkPreview() -> MsgContent {
switch (composeState.preview) {
case let .linkPreview(linkPreview: linkPreview):
if let url = parseMessage(msgText).url,
if let parsedMsg = parseSimpleXMarkdown(msgText),
let url = getSimplexLink(parsedMsg).url,
let linkPreview = linkPreview,
url == linkPreview.uri {
return .link(text: msgText, preview: linkPreview)
@ -1162,9 +1201,9 @@ struct ComposeView: View {
}
}
private func showLinkPreview(_ s: String) {
private func showLinkPreview(_ parsedMsg: [FormattedText]?) {
prevLinkUrl = linkUrl
(linkUrl, hasSimplexLink) = parseMessage(s)
(linkUrl, hasSimplexLink) = getSimplexLink(parsedMsg)
if let url = linkUrl {
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
pendingLinkUrl = url
@ -1181,8 +1220,8 @@ struct ComposeView: View {
}
}
private func parseMessage(_ msg: String) -> (url: URL?, hasSimplexLink: Bool) {
guard let parsedMsg = parseSimpleXMarkdown(msg) else { return (nil, false) }
private func getSimplexLink(_ parsedMsg: [FormattedText]?) -> (url: URL?, hasSimplexLink: Bool) {
guard let parsedMsg else { return (nil, false) }
let url: URL? = if let uri = parsedMsg.first(where: { ft in
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
}) {
@ -1234,18 +1273,21 @@ struct ComposeView_Previews: PreviewProvider {
static var previews: some View {
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
@State var composeState = ComposeState(message: "hello")
@State var selectedRange = NSRange()
return Group {
ComposeView(
chat: chat,
composeState: $composeState,
keyboardVisible: Binding.constant(true)
keyboardVisible: Binding.constant(true),
selectedRange: $selectedRange
)
.environmentObject(ChatModel())
ComposeView(
chat: chat,
composeState: $composeState,
keyboardVisible: Binding.constant(true)
keyboardVisible: Binding.constant(true),
selectedRange: $selectedRange
)
.environmentObject(ChatModel())
}

View file

@ -71,7 +71,7 @@ struct ContextItemView: View {
}
private func contextMsgPreview(_ contextItem: ChatItem) -> Text {
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary)
func attachment() -> Text {
let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) {

View file

@ -17,18 +17,19 @@ struct NativeTextEditor: UIViewRepresentable {
@Binding var height: CGFloat
@Binding var focused: Bool
@Binding var placeholder: String?
@Binding var selectedRange: NSRange
let onImagesAdded: ([UploadContent]) -> Void
private let minHeight: CGFloat = 37
private let defaultHeight: CGFloat = {
let field = CustomUITextField(height: Binding.constant(0))
let field = CustomUITextField(parent: nil, height: Binding.constant(0))
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down)
}()
func makeUIView(context: Context) -> UITextView {
let field = CustomUITextField(height: _height)
let field = CustomUITextField(parent: self, height: _height)
field.backgroundColor = .clear
field.text = text
field.textAlignment = alignment(text)
@ -69,6 +70,10 @@ struct NativeTextEditor: UIViewRepresentable {
if castedField.placeholder != placeholder {
castedField.placeholder = placeholder
}
if field.selectedRange != selectedRange {
field.selectedRange = selectedRange
}
}
private func updateHeight(_ field: UITextView) {
@ -99,6 +104,7 @@ private func alignment(_ text: String) -> NSTextAlignment {
}
private class CustomUITextField: UITextView, UITextViewDelegate {
var parent: NativeTextEditor?
var height: Binding<CGFloat>
var newHeight: CGFloat = 0
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
@ -106,7 +112,8 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
private let placeholderLabel: UILabel = UILabel()
init(height: Binding<CGFloat>) {
init(parent: NativeTextEditor?, height: Binding<CGFloat>) {
self.parent = parent
self.height = height
super.init(frame: .zero, textContainer: nil)
}
@ -232,10 +239,22 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
onFocusChanged(true)
updateSelectedRange(textView)
}
func textViewDidEndEditing(_ textView: UITextView) {
onFocusChanged(false)
updateSelectedRange(textView)
}
func textViewDidChangeSelection(_ textView: UITextView) {
updateSelectedRange(textView)
}
private func updateSelectedRange(_ textView: UITextView) {
if parent?.selectedRange != textView.selectedRange {
parent?.selectedRange = textView.selectedRange
}
}
}
@ -247,6 +266,7 @@ struct NativeTextEditor_Previews: PreviewProvider{
height: Binding.constant(100),
focused: Binding.constant(false),
placeholder: Binding.constant("Placeholder"),
selectedRange: Binding.constant(NSRange(location: 0, length: 0)),
onImagesAdded: { _ in }
)
.fixedSize(horizontal: false, vertical: true)

View file

@ -13,6 +13,7 @@ private let liveMsgInterval: UInt64 = 3000_000000
struct SendMessageView: View {
@Binding var composeState: ComposeState
@Binding var selectedRange: NSRange
@EnvironmentObject var theme: AppTheme
var sendMessage: (Int?) -> Void
var sendLiveMessage: (() async -> Void)? = nil
@ -62,6 +63,7 @@ struct SendMessageView: View {
height: $teHeight,
focused: $keyboardVisible,
placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
selectedRange: $selectedRange,
onImagesAdded: onMediaAdded
)
.allowsTightening(false)
@ -424,8 +426,10 @@ struct SendMessageView: View {
struct SendMessageView_Previews: PreviewProvider {
static var previews: some View {
@State var composeStateNew = ComposeState()
@State var selectedRange = NSRange()
let ci = ChatItem.getSample(1, .directSnd, .now, "hello")
@State var composeStateEditing = ComposeState(editingItem: ci)
@State var selectedRangeEditing = NSRange()
@State var sendEnabled: Bool = true
return Group {
@ -434,6 +438,7 @@ struct SendMessageView_Previews: PreviewProvider {
Spacer(minLength: 0)
SendMessageView(
composeState: $composeStateNew,
selectedRange: $selectedRange,
sendMessage: { _ in },
onMediaAdded: { _ in },
keyboardVisible: Binding.constant(true)
@ -444,6 +449,7 @@ struct SendMessageView_Previews: PreviewProvider {
Spacer(minLength: 0)
SendMessageView(
composeState: $composeStateEditing,
selectedRange: $selectedRangeEditing,
sendMessage: { _ in },
onMediaAdded: { _ in },
keyboardVisible: Binding.constant(true)

View file

@ -138,12 +138,12 @@ struct GroupChatInfoView: View {
addMembersButton()
}
}
if members.count > 8 {
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
.padding(.leading, 8)
}
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
.padding(.leading, 8)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) }
let filteredMembers = s == ""
? members
: members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
ForEach(filteredMembers) { member in
ZStack {
@ -276,7 +276,9 @@ struct GroupChatInfoView: View {
if groupInfo.canAddMembers {
addMembersActionButton(width: buttonWidth)
}
muteButton(width: buttonWidth)
if let nextNtfMode = chat.chatInfo.nextNtfMode {
muteButton(width: buttonWidth, nextNtfMode: nextNtfMode)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
@ -324,13 +326,13 @@ struct GroupChatInfoView: View {
}
}
private func muteButton(width: CGFloat) -> some View {
InfoViewButton(
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
return InfoViewButton(
image: nextNtfMode.iconFilled,
title: "\(nextNtfMode.text(mentions: true))",
width: width
) {
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
toggleNotifications(chat, enableNtfs: nextNtfMode)
}
.disabled(!groupInfo.ready)
}
@ -353,11 +355,7 @@ struct GroupChatInfoView: View {
.onAppear {
searchFocussed = false
Task {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
chatModel.populateGroupMembersIndexes()
}
await chatModel.loadGroupMembers(groupInfo)
}
}
}
@ -387,7 +385,7 @@ struct GroupChatInfoView: View {
Spacer()
memberInfo(member)
}
if user {
v
} else if groupInfo.membership.memberRole >= .admin {
@ -490,7 +488,7 @@ struct GroupChatInfoView: View {
.foregroundColor(theme.colors.secondary)
}
}
private func memberInfoView(_ groupMember: GMember) -> some View {
GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
.navigationBarHidden(false)

View file

@ -0,0 +1,234 @@
//
// GroupMentions.swift
// SimpleX (iOS)
//
// Created by Diogo Cunha on 30/01/2025.
// Copyright © 2025 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
let MENTION_START: Character = "@"
let QUOTE: Character = "'"
let MEMBER_ROW_SIZE: CGFloat = 60
let MAX_VISIBLE_MEMBER_ROWS: CGFloat = 4.8
struct GroupMentionsView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
var groupInfo: GroupInfo
@Binding var composeState: ComposeState
@Binding var selectedRange: NSRange
@Binding var keyboardVisible: Bool
@State private var isVisible = false
@State private var currentMessage: String = ""
@State private var mentionName: String = ""
@State private var mentionRange: NSRange?
@State private var mentionMemberId: String?
var body: some View {
ZStack {
if isVisible {
Color.white.opacity(0.01)
.edgesIgnoringSafeArea(.all)
.onTapGesture {
isVisible = false
}
}
VStack {
Spacer()
VStack {
Spacer()
VStack {
Divider()
let list = List {
ForEach(filteredMembers, id: \.wrapped.groupMemberId) { member in
let mentioned = mentionMemberId == member.wrapped.memberId
let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned
memberRowView(member.wrapped, mentioned)
.contentShape(Rectangle())
.disabled(disabled)
.opacity(disabled ? 0.6 : 1)
.onTapGesture {
memberSelected(member)
}
}
}
.listStyle(PlainListStyle())
.frame(height: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filteredMembers.count)))
if #available(iOS 16.0, *) {
list.scrollDismissesKeyboard(.never)
} else {
list
}
}
.background(Color(UIColor.systemBackground))
}
.frame(maxWidth: .infinity, maxHeight: MEMBER_ROW_SIZE * MAX_VISIBLE_MEMBER_ROWS)
}
.offset(y: isVisible ? 0 : 300)
.animation(.spring(), value: isVisible)
.onChange(of: composeState.parsedMessage) { parsedMsg in
currentMessage = composeState.message
messageChanged(currentMessage, parsedMsg, selectedRange)
}
.onChange(of: selectedRange) { r in
// This condition is needed to prevent messageChanged called twice,
// because composeState.formattedText triggers later when message changes.
// The condition is only true if position changed without text change
if currentMessage == composeState.message {
messageChanged(currentMessage, composeState.parsedMessage, r)
}
}
.onAppear {
currentMessage = composeState.message
}
}
}
private var filteredMembers: [GMember] {
let members = m.groupMembers
.filter { m in
let status = m.wrapped.memberStatus
return status != .memLeft && status != .memRemoved && status != .memInvited
}
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
let s = mentionName.lowercased()
return s.isEmpty
? members
: members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
}
private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) {
removeUnusedMentions(parsedMsg)
if let (ft, r) = selectedMarkdown(parsedMsg, range) {
switch ft.format {
case let .mention(name):
isVisible = true
mentionName = name
mentionRange = r
mentionMemberId = composeState.mentions[name]?.memberId
if !m.membersLoaded {
Task { await m.loadGroupMembers(groupInfo) }
}
return
case .none: () //
let pos = range.location
if range.length == 0, let (at, atRange) = getCharacter(msg, pos - 1), at == "@" {
let prevChar = getCharacter(msg, pos - 2)?.char
if prevChar == nil || prevChar == " " || prevChar == "\n" {
isVisible = true
mentionName = ""
mentionRange = atRange
mentionMemberId = nil
Task { await m.loadGroupMembers(groupInfo) }
return
}
}
default: ()
}
}
closeMemberList()
}
private func removeUnusedMentions(_ parsedMsg: [FormattedText]) {
let usedMentions: Set<String> = Set(parsedMsg.compactMap { ft in
if case let .mention(name) = ft.format { name } else { nil }
})
if usedMentions.count < composeState.mentions.count {
composeState = composeState.copy(mentions: composeState.mentions.filter({ usedMentions.contains($0.key) }))
}
}
private func getCharacter(_ s: String, _ pos: Int) -> (char: String.SubSequence, range: NSRange)? {
if pos < 0 || pos >= s.count { return nil }
let r = NSRange(location: pos, length: 1)
return if let range = Range(r, in: s) {
(s[range], r)
} else {
nil
}
}
private func selectedMarkdown(_ parsedMsg: [FormattedText], _ range: NSRange) -> (FormattedText, NSRange)? {
if parsedMsg.isEmpty { return nil }
var i = 0
var pos: Int = 0
while i < parsedMsg.count && pos + parsedMsg[i].text.count < range.location {
pos += parsedMsg[i].text.count
i += 1
}
// the second condition will be true when two markdowns are selected
return i >= parsedMsg.count || range.location + range.length > pos + parsedMsg[i].text.count
? nil
: (parsedMsg[i], NSRange(location: pos, length: parsedMsg[i].text.count))
}
private func memberSelected(_ member: GMember) {
if let range = mentionRange, mentionMemberId == nil || mentionMemberId != member.wrapped.memberId {
addMemberMention(member, range)
}
}
private func addMemberMention(_ member: GMember, _ r: NSRange) {
guard let range = Range(r, in: composeState.message) else { return }
var mentions = composeState.mentions
var newName: String
if let mm = mentions.first(where: { $0.value.memberId == member.wrapped.memberId }) {
newName = mm.key
} else {
newName = composeState.mentionMemberName(member.wrapped.memberProfile.displayName)
}
mentions[newName] = CIMention(groupMember: member.wrapped)
var msgMention = "@" + (newName.contains(" ") ? "'\(newName)'" : newName)
var newPos = r.location + msgMention.count
let newMsgLength = composeState.message.count + msgMention.count - r.length
print(newPos)
print(newMsgLength)
if newPos == newMsgLength {
msgMention += " "
newPos += 1
}
composeState = composeState.copy(
message: composeState.message.replacingCharacters(in: range, with: msgMention),
mentions: mentions
)
selectedRange = NSRange(location: newPos, length: 0)
closeMemberList()
keyboardVisible = true
}
private func closeMemberList() {
isVisible = false
mentionName = ""
mentionRange = nil
mentionMemberId = nil
}
private func memberRowView(_ member: GroupMember, _ mentioned: Bool) -> some View {
return HStack{
MemberProfileImage(member, size: 38)
.padding(.trailing, 2)
VStack(alignment: .leading) {
let t = Text(member.localAliasAndFullName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
(member.verified ? memberVerifiedShield() + t : t)
.lineLimit(1)
}
Spacer()
if mentioned {
Image(systemName: "checkmark")
}
}
func memberVerifiedShield() -> Text {
(Text(Image(systemName: "checkmark.shield")) + textSpace)
.font(.caption)
.baselineOffset(2)
.kerning(-2)
.foregroundColor(theme.colors.secondary)
}
}
}

View file

@ -58,7 +58,7 @@ struct GroupWelcomeView: View {
}
private func textPreview() -> some View {
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false, secondaryColor: theme.colors.secondary)
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, mentions: nil, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary)
.frame(minHeight: 130, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
}

View file

@ -302,14 +302,14 @@ struct ChatListNavLink: View {
}
@ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View {
Button {
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
} label: {
if chat.chatInfo.ntfsEnabled {
SwipeLabel(NSLocalizedString("Mute", comment: "swipe action"), systemImage: "speaker.slash.fill", inverted: oneHandUI)
} else {
SwipeLabel(NSLocalizedString("Unmute", comment: "swipe action"), systemImage: "speaker.wave.2.fill", inverted: oneHandUI)
if let nextMode = chat.chatInfo.nextNtfMode {
Button {
toggleNotifications(chat, enableNtfs: nextMode)
} label: {
SwipeLabel(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.iconFilled, inverted: oneHandUI)
}
} else {
EmptyView()
}
}

View file

@ -480,7 +480,7 @@ struct ChatListView: View {
switch chatTagsModel.activeFilter {
case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)
case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true
case .unread: chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0
case .unread: chat.unreadTag
case .none: true
}
}

View file

@ -188,12 +188,14 @@ struct ChatPreviewView: View {
private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View {
ZStack(alignment: .topTrailing) {
let s = chat.chatStats
let mentionWidth: CGFloat = if s.unreadMentions > 0 && s.unreadCount > 1 { dynamicSize(userFont).unreadCorner } else { 0 }
let t = text
.lineLimit(userFont <= .xxxLarge ? 2 : 1)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, hasFilePreview ? 0 : 8)
.padding(.trailing, hasFilePreview ? 38 : 36)
.padding(.trailing, mentionWidth + (hasFilePreview ? 38 : 36))
.offset(x: hasFilePreview ? -2 : 0)
.fixedSize(horizontal: false, vertical: true)
if !showChatPreviews && !draft {
@ -208,19 +210,34 @@ struct ChatPreviewView: View {
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat {
unreadCountText(s.unreadCount)
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(dynamicSize(userFont).unreadCorner)
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
Image(systemName: "speaker.slash.fill")
let mentionColor = mentionColor(chat)
HStack(alignment: .center, spacing: 2) {
if s.unreadMentions > 0 && s.unreadCount > 1 {
Text("\(MENTION_START)")
.font(userFont <= .xxxLarge ? .body : .callout)
.foregroundColor(mentionColor)
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
.cornerRadius(dynamicSize(userFont).unreadCorner)
.padding(.bottom, 1)
}
let singleUnreadIsMention = s.unreadMentions > 0 && s.unreadCount == 1
(singleUnreadIsMention ? Text("\(MENTION_START)") : unreadCountText(s.unreadCount))
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
.background(singleUnreadIsMention ? mentionColor : chat.chatInfo.ntfsEnabled(false) || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(dynamicSize(userFont).unreadCorner)
}
.frame(height: dynamicChatInfoSize)
} else if let ntfMode = chat.chatInfo.chatSettings?.enableNtfs, ntfMode != .all {
let iconSize = ntfMode == .mentions ? dynamicChatInfoSize * 0.8 : dynamicChatInfoSize
let iconColor = ntfMode == .mentions ? theme.colors.secondary.opacity(0.7) : theme.colors.secondary
Image(systemName: ntfMode.iconFilled)
.resizable()
.scaledToFill()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.foregroundColor(theme.colors.secondary)
.frame(width: iconSize, height: iconSize)
.foregroundColor(iconColor)
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
@ -232,12 +249,20 @@ struct ChatPreviewView: View {
Color.clear.frame(width: 0)
}
}
private func mentionColor(_ chat: Chat) -> Color {
switch chat.chatInfo.chatSettings?.enableNtfs {
case .all: theme.colors.primary
case .mentions: theme.colors.primary
default: theme.colors.secondary
}
}
private func messageDraft(_ draft: ComposeState) -> Text {
let msg = draft.message
return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary)
+ attachment()
+ messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
+ messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary)
func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text {
Text(Image(systemName: s)).foregroundColor(color) + textSpace
@ -256,7 +281,7 @@ struct ChatPreviewView: View {
func chatItemPreview(_ cItem: ChatItem) -> Text {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix())
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix())
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
// can be refactored into a single function if functions calling these are changed to return same type

View file

@ -191,11 +191,7 @@ struct AddGroupView: View {
profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on))
let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile)
Task {
let groupMembers = await apiListMembers(gInfo.groupId)
await MainActor.run {
m.groupMembers = groupMembers.map { GMember.init($0) }
m.populateGroupMembersIndexes()
}
await m.loadGroupMembers(gInfo)
}
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
m.addChat(c)

View file

@ -18,6 +18,7 @@ struct TerminalView: View {
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State var composeState: ComposeState = ComposeState()
@State var selectedRange = NSRange()
@State private var keyboardVisible = false
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
@State private var terminalItem: TerminalItem?
@ -96,6 +97,7 @@ struct TerminalView: View {
SendMessageView(
composeState: $composeState,
selectedRange: $selectedRange,
sendMessage: { _ in consoleSendMessage() },
showVoiceMessageButton: false,
onMediaAdded: { _ in },

View file

@ -789,7 +789,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)?
if let file = cItem.autoReceiveFile() {
cItem = autoReceiveFile(file) ?? cItem
}
let ntf: NSENotificationData = (cInfo.ntfsEnabled && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf
let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf
return (chatItem.chatId, ntf)
} else {
return nil

View file

@ -205,6 +205,7 @@
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
B70A39732D24090D00E80A5F /* TagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70A39722D24090D00E80A5F /* TagListView.swift */; };
B70CE9E62D4BE5930080F36D /* GroupMentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70CE9E52D4BE5930080F36D /* GroupMentions.swift */; };
B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */; };
B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; };
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; };
@ -556,6 +557,7 @@
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
B70A39722D24090D00E80A5F /* TagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagListView.swift; sourceTree = "<group>"; };
B70CE9E52D4BE5930080F36D /* GroupMentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMentions.swift; sourceTree = "<group>"; };
B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = "<group>"; };
@ -1096,6 +1098,7 @@
5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */,
6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */,
1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */,
B70CE9E52D4BE5930080F36D /* GroupMentions.swift */,
);
path = Group;
sourceTree = "<group>";
@ -1452,6 +1455,7 @@
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */,
5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */,
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */,
B70CE9E62D4BE5930080F36D /* GroupMentions.swift in Sources */,
CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */,
64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */,
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */,

View file

@ -52,7 +52,7 @@ public enum ChatCommand {
case apiReorderChatTags(tagIds: [Int64])
case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String)
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool)
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool)
case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode)
case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64])
case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction)
@ -226,7 +226,7 @@ public enum ChatCommand {
return "/_create *\(noteFolderId) json \(msgs)"
case let .apiReportMessage(groupId, chatItemId, reportReason, reportText):
return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)"
case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)"
case let .apiUpdateChatItem(type, id, itemId, um, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(um.cmdString)"
case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)"
case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))"
@ -1229,11 +1229,27 @@ public struct ComposedMessage: Encodable {
public var fileSource: CryptoFile?
var quotedItemId: Int64?
public var msgContent: MsgContent
public var mentions: [String: Int64]
public init(fileSource: CryptoFile? = nil, quotedItemId: Int64? = nil, msgContent: MsgContent) {
public init(fileSource: CryptoFile? = nil, quotedItemId: Int64? = nil, msgContent: MsgContent, mentions: [String: Int64] = [:]) {
self.fileSource = fileSource
self.quotedItemId = quotedItemId
self.msgContent = msgContent
self.mentions = mentions
}
}
public struct UpdatedMessage: Encodable {
public var msgContent: MsgContent
public var mentions: [String: Int64]
public init(msgContent: MsgContent, mentions: [String: Int64] = [:]) {
self.msgContent = msgContent
self.mentions = mentions
}
var cmdString: String {
"json \(encodeJSON(self))"
}
}
@ -2027,6 +2043,41 @@ public enum MsgFilter: String, Codable, Hashable {
case none
case all
case mentions
public func nextMode(mentions: Bool) -> MsgFilter {
switch self {
case .all: mentions ? .mentions : .none
case .mentions: .none
case .none: .all
}
}
public func text(mentions: Bool) -> String {
switch self {
case .all: NSLocalizedString("Unmute", comment: "notification label action")
case .mentions: NSLocalizedString("Mute", comment: "notification label action")
case .none:
mentions
? NSLocalizedString("Mute all", comment: "notification label action")
: NSLocalizedString("Mute", comment: "notification label action")
}
}
public var icon: String {
return switch self {
case .all: "speaker.wave.2"
case .mentions: "speaker.badge.exclamationmark"
case .none: "speaker.slash"
}
}
public var iconFilled: String {
return switch self {
case .all: "speaker.wave.2.fill"
case .mentions: "speaker.badge.exclamationmark.fill"
case .none: "speaker.slash.fill"
}
}
}
public struct UserMsgReceiptSettings: Codable {

View file

@ -1447,9 +1447,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
return .other
}
}
public var ntfsEnabled: Bool {
self.chatSettings?.enableNtfs == .all
public func ntfsEnabled(chatItem: ChatItem) -> Bool {
ntfsEnabled(chatItem.meta.userMention)
}
public func ntfsEnabled(_ userMention: Bool) -> Bool {
switch self.chatSettings?.enableNtfs {
case .all: true
case .mentions: userMention
default: false
}
}
public var chatSettings: ChatSettings? {
@ -1460,6 +1468,14 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
}
}
public var nextNtfMode: MsgFilter? {
self.chatSettings?.enableNtfs.nextMode(mentions: hasMentions)
}
public var hasMentions: Bool {
if case .group = self { true } else { false }
}
public var chatTags: [Int64]? {
switch self {
case let .direct(contact): return contact.chatTags
@ -1559,14 +1575,16 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike {
}
public struct ChatStats: Decodable, Hashable {
public init(unreadCount: Int = 0, reportsCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) {
public init(unreadCount: Int = 0, unreadMentions: Int = 0, reportsCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) {
self.unreadCount = unreadCount
self.unreadMentions = unreadMentions
self.reportsCount = reportsCount
self.minUnreadItemId = minUnreadItemId
self.unreadChat = unreadChat
}
public var unreadCount: Int = 0
public var unreadMentions: Int = 0
// actual only via getChats() and getChat(.initial), otherwise, zero
public var reportsCount: Int = 0
public var minUnreadItemId: Int64 = 0
@ -2085,6 +2103,16 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
? String.localizedStringWithFormat(NSLocalizedString("Past member %@", comment: "past/unknown group member"), name)
: name
}
public var localAliasAndFullName: String {
get {
let p = memberProfile
let fullName = p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)")
let name = p.localAlias == "" ? fullName : "\(p.localAlias) (\(fullName))"
return pastMember(name)
}
}
public var memberActive: Bool {
switch memberStatus {
@ -2392,6 +2420,28 @@ public struct AChatItem: Decodable, Hashable {
}
}
public struct CIMentionMember: Decodable, Hashable {
public var groupMemberId: Int64
public var displayName: String
public var localAlias: String?
public var memberRole: GroupMemberRole
}
public struct CIMention: Decodable, Hashable {
public var memberId: String
public var memberRef: CIMentionMember?
public init(groupMember m: GroupMember) {
self.memberId = m.memberId
self.memberRef = CIMentionMember(
groupMemberId: m.groupMemberId,
displayName: m.memberProfile.displayName,
localAlias: m.memberProfile.localAlias,
memberRole: m.memberRole
)
}
}
public struct ACIReaction: Decodable, Hashable {
public var chatInfo: ChatInfo
public var chatReaction: CIReaction
@ -2410,11 +2460,12 @@ public struct CIReaction: Decodable, Hashable {
}
public struct ChatItem: Identifiable, Decodable, Hashable {
public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) {
public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) {
self.chatDir = chatDir
self.meta = meta
self.content = content
self.formattedText = formattedText
self.mentions = mentions
self.quotedItem = quotedItem
self.reactions = reactions
self.file = file
@ -2424,6 +2475,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
public var meta: CIMeta
public var content: CIContent
public var formattedText: [FormattedText]?
public var mentions: [String: CIMention]?
public var quotedItem: CIQuote?
public var reactions: [CIReactionCount]
public var file: CIFile?
@ -2432,7 +2484,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
public var isLiveDummy: Bool = false
private enum CodingKeys: String, CodingKey {
case chatDir, meta, content, formattedText, quotedItem, reactions, file
case chatDir, meta, content, formattedText, mentions, quotedItem, reactions, file
}
public var id: Int64 { meta.itemId }
@ -2743,6 +2795,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
itemDeleted: nil,
itemEdited: false,
itemLive: false,
userMention: false,
deletable: false,
editable: false
),
@ -2765,6 +2818,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
itemDeleted: nil,
itemEdited: false,
itemLive: false,
userMention: false,
deletable: false,
editable: false
),
@ -2787,6 +2841,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
itemDeleted: nil,
itemEdited: false,
itemLive: true,
userMention: false,
deletable: false,
editable: false
),
@ -2860,6 +2915,7 @@ public struct CIMeta: Decodable, Hashable {
public var itemEdited: Bool
public var itemTimed: CITimed?
public var itemLive: Bool?
public var userMention: Bool
public var deletable: Bool
public var editable: Bool
@ -2884,6 +2940,7 @@ public struct CIMeta: Decodable, Hashable {
itemDeleted: itemDeleted,
itemEdited: itemEdited,
itemLive: itemLive,
userMention: false,
deletable: deletable,
editable: editable
)
@ -2900,6 +2957,7 @@ public struct CIMeta: Decodable, Hashable {
itemDeleted: nil,
itemEdited: false,
itemLive: false,
userMention: false,
deletable: false,
editable: false
)
@ -3921,6 +3979,12 @@ public struct FormattedText: Decodable, Hashable {
public var text: String
public var format: Format?
public static func plain(_ text: String) -> [FormattedText] {
text.isEmpty
? []
: [FormattedText(text: text, format: nil)]
}
public var isSecret: Bool {
if case .secret = format { true } else { false }
}
@ -3935,6 +3999,7 @@ public enum Format: Decodable, Equatable, Hashable {
case colored(color: FormatColor)
case uri
case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String])
case mention(memberName: String)
case email
case phone

View file

@ -40,7 +40,7 @@ public struct ErrorAlert: Error {
self = if let chatResponse = error as? ChatResponse {
ErrorAlert(chatResponse)
} else {
ErrorAlert(LocalizedStringKey(error.localizedDescription))
ErrorAlert("\(error.localizedDescription)")
}
}

View file

@ -150,6 +150,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, image,
DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId)
pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order, displayName, fullName, image, Nothing, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, Nothing, Nothing, Nothing, Nothing)
-- TODO [mentions]
getUsersInfo :: DB.Connection -> IO [UserInfo]
getUsersInfo db = getUsers db >>= mapM getUserInfo
where