mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-28 20:29:53 +00:00
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:
parent
760ea17fb9
commit
517679e2df
26 changed files with 664 additions and 147 deletions
|
@ -499,7 +499,7 @@ final class ChatModel: ObservableObject {
|
||||||
[cItem]
|
[cItem]
|
||||||
}
|
}
|
||||||
if case .rcvNew = cItem.meta.itemStatus {
|
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)
|
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
|
||||||
} else {
|
} else {
|
||||||
|
@ -579,7 +579,7 @@ final class ChatModel: ObservableObject {
|
||||||
|
|
||||||
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
if cItem.isRcvNew {
|
if cItem.isRcvNew {
|
||||||
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
|
unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0)
|
||||||
}
|
}
|
||||||
// update previews
|
// update previews
|
||||||
if let chat = getChat(cInfo.id) {
|
if let chat = getChat(cInfo.id) {
|
||||||
|
@ -662,7 +662,7 @@ final class ChatModel: ObservableObject {
|
||||||
func markChatItemsRead(_ cInfo: ChatInfo) {
|
func markChatItemsRead(_ cInfo: ChatInfo) {
|
||||||
// update preview
|
// update preview
|
||||||
_updateChat(cInfo.id) { chat in
|
_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)
|
self.updateFloatingButtons(unreadCount: 0)
|
||||||
ChatTagsModel.shared.markChatTagRead(chat)
|
ChatTagsModel.shared.markChatTagRead(chat)
|
||||||
chat.chatStats = ChatStats()
|
chat.chatStats = ChatStats()
|
||||||
|
@ -693,20 +693,28 @@ final class ChatModel: ObservableObject {
|
||||||
markCurrentChatRead(fromIndex: i)
|
markCurrentChatRead(fromIndex: i)
|
||||||
_updateChat(cInfo.id) { chat in
|
_updateChat(cInfo.id) { chat in
|
||||||
var unreadBelow = 0
|
var unreadBelow = 0
|
||||||
|
var unreadMentionsBelow = 0
|
||||||
var j = i - 1
|
var j = i - 1
|
||||||
while j >= 0 {
|
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
|
unreadBelow += 1
|
||||||
|
if meta.userMention {
|
||||||
|
unreadMentionsBelow += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
j -= 1
|
j -= 1
|
||||||
}
|
}
|
||||||
// update preview
|
// update preview
|
||||||
let markedCount = chat.chatStats.unreadCount - unreadBelow
|
let markedCount = chat.chatStats.unreadCount - unreadBelow
|
||||||
if markedCount > 0 {
|
let markedMentionsCount = chat.chatStats.unreadMentions - unreadMentionsBelow
|
||||||
|
if markedCount > 0 || markedMentionsCount > 0 {
|
||||||
let wasUnread = chat.unreadTag
|
let wasUnread = chat.unreadTag
|
||||||
chat.chatStats.unreadCount -= markedCount
|
chat.chatStats.unreadCount -= markedCount
|
||||||
|
chat.chatStats.unreadMentions -= markedMentionsCount
|
||||||
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
|
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)
|
self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -727,7 +735,7 @@ final class ChatModel: ObservableObject {
|
||||||
func clearChat(_ cInfo: ChatInfo) {
|
func clearChat(_ cInfo: ChatInfo) {
|
||||||
// clear preview
|
// clear preview
|
||||||
if let chat = getChat(cInfo.id) {
|
if let chat = getChat(cInfo.id) {
|
||||||
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
|
self.decreaseUnreadCounter(user: self.currentUser!, chat: chat)
|
||||||
chat.chatItems = []
|
chat.chatItems = []
|
||||||
ChatTagsModel.shared.markChatTagRead(chat)
|
ChatTagsModel.shared.markChatTagRead(chat)
|
||||||
chat.chatStats = ChatStats()
|
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 {
|
if self.chatId == cInfo.id {
|
||||||
for itemId in itemIds {
|
for itemId in itemIds {
|
||||||
if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) {
|
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()
|
private let unreadCollector = UnreadCollector()
|
||||||
|
@ -756,16 +764,16 @@ final class ChatModel: ObservableObject {
|
||||||
class UnreadCollector {
|
class UnreadCollector {
|
||||||
private let subject = PassthroughSubject<Void, Never>()
|
private let subject = PassthroughSubject<Void, Never>()
|
||||||
private var bag = Set<AnyCancellable>()
|
private var bag = Set<AnyCancellable>()
|
||||||
private var unreadCounts: [ChatId: Int] = [:]
|
private var unreadCounts: [ChatId: (unread: Int, mentions: Int)] = [:]
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
subject
|
subject
|
||||||
.debounce(for: 1, scheduler: DispatchQueue.main)
|
.debounce(for: 1, scheduler: DispatchQueue.main)
|
||||||
.sink {
|
.sink {
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
for (chatId, count) in self.unreadCounts {
|
for (chatId, (unread, mentions)) in self.unreadCounts {
|
||||||
if let i = m.getChatIndex(chatId) {
|
if unread != 0 || mentions != 0, let i = m.getChatIndex(chatId) {
|
||||||
m.changeUnreadCounter(i, by: count)
|
m.changeUnreadCounter(i, by: unread, unreadMentions: mentions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.unreadCounts = [:]
|
self.unreadCounts = [:]
|
||||||
|
@ -773,11 +781,12 @@ final class ChatModel: ObservableObject {
|
||||||
.store(in: &bag)
|
.store(in: &bag)
|
||||||
}
|
}
|
||||||
|
|
||||||
func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
|
func changeUnreadCounter(_ chatId: ChatId, by count: Int, unreadMentions: Int) {
|
||||||
if chatId == ChatModel.shared.chatId {
|
if chatId == ChatModel.shared.chatId {
|
||||||
ChatView.FloatingButtonModel.shared.totalUnread += count
|
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()
|
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
|
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)
|
ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread)
|
||||||
changeUnreadCounter(user: currentUser!, by: count)
|
changeUnreadCounter(user: currentUser!, by: count)
|
||||||
}
|
}
|
||||||
|
@ -866,6 +877,13 @@ final class ChatModel: ObservableObject {
|
||||||
changeUnreadCounter(user: user, by: 1)
|
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) {
|
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
|
||||||
changeUnreadCounter(user: user, by: -by)
|
changeUnreadCounter(user: user, by: -by)
|
||||||
}
|
}
|
||||||
|
@ -878,8 +896,20 @@ final class ChatModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func totalUnreadCountForAllUsers() -> Int {
|
func totalUnreadCountForAllUsers() -> Int {
|
||||||
chats.filter { $0.chatInfo.ntfsEnabled }.reduce(0, { count, chat in count + chat.chatStats.unreadCount }) +
|
var unread: Int = 0
|
||||||
users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
|
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) {
|
func increaseGroupReportsCounter(_ chatId: ChatId) {
|
||||||
|
@ -1104,7 +1134,11 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
|
||||||
}
|
}
|
||||||
|
|
||||||
var unreadTag: Bool {
|
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 } }
|
var id: ChatId { get { chatInfo.id } }
|
||||||
|
|
|
@ -248,7 +248,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||||
|
|
||||||
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
logger.debug("NtfManager.notifyMessageReceived")
|
logger.debug("NtfManager.notifyMessageReceived")
|
||||||
if cInfo.ntfsEnabled {
|
if cInfo.ntfsEnabled(chatItem: cItem) {
|
||||||
addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0))
|
addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
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, msg: msg, live: live), bgDelay: msgDelay)
|
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 }
|
if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem }
|
||||||
throw r
|
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 {
|
do {
|
||||||
try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
|
try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
ChatModel.shared.markChatItemsRead(cInfo, itemIds)
|
ChatModel.shared.markChatItemsRead(cInfo, itemIds, mentionsRead)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("apiChatItemsRead error: \(responseError(error))")
|
logger.error("apiChatItemsRead error: \(responseError(error))")
|
||||||
|
@ -1576,6 +1576,7 @@ func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo {
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// use ChatModel's loadGroupMembers from views
|
||||||
func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
|
func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
|
||||||
let r = await chatSendCmd(.apiListMembers(groupId: groupId))
|
let r = await chatSendCmd(.apiListMembers(groupId: groupId))
|
||||||
if case let .groupMembers(_, group) = r { return group.members }
|
if case let .groupMembers(_, group) = r { return group.members }
|
||||||
|
@ -2027,7 +2028,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||||
if cItem.isActiveReport {
|
if cItem.isActiveReport {
|
||||||
m.increaseGroupReportsCounter(cInfo.id)
|
m.increaseGroupReportsCounter(cInfo.id)
|
||||||
}
|
}
|
||||||
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
|
} else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) {
|
||||||
m.increaseUnreadCounter(user: user)
|
m.increaseUnreadCounter(user: user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2072,7 +2073,8 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||||
case let .chatItemsDeleted(user, items, _):
|
case let .chatItemsDeleted(user, items, _):
|
||||||
if !active(user) {
|
if !active(user) {
|
||||||
for item in items {
|
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 {
|
await MainActor.run {
|
||||||
m.decreaseUnreadCounter(user: user)
|
m.decreaseUnreadCounter(user: user)
|
||||||
}
|
}
|
||||||
|
|
|
@ -158,7 +158,9 @@ struct ChatInfoView: View {
|
||||||
searchButton(width: buttonWidth)
|
searchButton(width: buttonWidth)
|
||||||
AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
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) }
|
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)
|
.padding(.trailing)
|
||||||
|
@ -432,13 +434,13 @@ struct ChatInfoView: View {
|
||||||
.disabled(!contact.ready || chat.chatItems.isEmpty)
|
.disabled(!contact.ready || chat.chatItems.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func muteButton(width: CGFloat) -> some View {
|
private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
|
||||||
InfoViewButton(
|
return InfoViewButton(
|
||||||
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
image: nextNtfMode.iconFilled,
|
||||||
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
|
title: "\(nextNtfMode.text(mentions: false))",
|
||||||
width: width
|
width: width
|
||||||
) {
|
) {
|
||||||
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
toggleNotifications(chat, enableNtfs: nextNtfMode)
|
||||||
}
|
}
|
||||||
.disabled(!contact.ready || !contact.active)
|
.disabled(!contact.ready || !contact.active)
|
||||||
}
|
}
|
||||||
|
|
|
@ -254,7 +254,7 @@ struct FramedItemView: View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(sender)
|
Text(sender)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(theme.colors.secondary)
|
.foregroundColor(qi.chatDir == .groupSnd ? .accentColor : theme.colors.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
ciQuotedMsgTextView(qi, lines: 2)
|
ciQuotedMsgTextView(qi, lines: 2)
|
||||||
}
|
}
|
||||||
|
@ -302,6 +302,8 @@ struct FramedItemView: View {
|
||||||
text: text,
|
text: text,
|
||||||
formattedText: ft,
|
formattedText: ft,
|
||||||
meta: ci.meta,
|
meta: ci.meta,
|
||||||
|
mentions: ci.mentions,
|
||||||
|
userMemberId: chat.chatInfo.groupInfo?.membership.memberId,
|
||||||
rightToLeft: rtl,
|
rightToLeft: rtl,
|
||||||
showSecrets: showSecrets,
|
showSecrets: showSecrets,
|
||||||
prefix: txtPrefix
|
prefix: txtPrefix
|
||||||
|
|
|
@ -32,6 +32,8 @@ struct MsgContentView: View {
|
||||||
var formattedText: [FormattedText]? = nil
|
var formattedText: [FormattedText]? = nil
|
||||||
var sender: String? = nil
|
var sender: String? = nil
|
||||||
var meta: CIMeta? = nil
|
var meta: CIMeta? = nil
|
||||||
|
var mentions: [String: CIMention]? = nil
|
||||||
|
var userMemberId: String? = nil
|
||||||
var rightToLeft = false
|
var rightToLeft = false
|
||||||
var showSecrets: Bool
|
var showSecrets: Bool
|
||||||
var prefix: Text? = nil
|
var prefix: Text? = nil
|
||||||
|
@ -68,7 +70,7 @@ struct MsgContentView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func msgContentView() -> Text {
|
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 let mt = meta {
|
||||||
if mt.isLive {
|
if mt.isLive {
|
||||||
v = v + typingIndicator(mt.recent)
|
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
|
let s = text
|
||||||
var res: Text
|
var res: Text
|
||||||
|
|
||||||
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
|
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
|
var i = 1
|
||||||
while i < ft.count {
|
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
|
i = i + 1
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
let t = ft.text
|
||||||
if let f = ft.format {
|
if let f = ft.format {
|
||||||
switch (f) {
|
switch (f) {
|
||||||
|
@ -144,6 +146,21 @@ private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool)
|
||||||
case .full: return linkText(t, simplexUri, preview, prefix: "")
|
case .full: return linkText(t, simplexUri, preview, prefix: "")
|
||||||
case .browser: 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 .email: return linkText(t, t, preview, prefix: "mailto:")
|
||||||
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
|
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 {
|
private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text {
|
||||||
preview
|
preview
|
||||||
? Text(s).foregroundColor(color).underline(color: color)
|
? Text(s).foregroundColor(color).underline(color: color)
|
||||||
|
|
|
@ -14,6 +14,7 @@ struct ChatItemInfoView: View {
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var ci: ChatItem
|
var ci: ChatItem
|
||||||
|
var userMemberId: String?
|
||||||
@Binding var chatItemInfo: ChatItemInfo?
|
@Binding var chatItemInfo: ChatItemInfo?
|
||||||
@State private var selection: CIInfoTab = .history
|
@State private var selection: CIInfoTab = .history
|
||||||
@State private var alert: CIInfoViewAlert? = nil
|
@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 {
|
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View {
|
||||||
if text != "" {
|
if text != "" {
|
||||||
TextBubble(text: text, formattedText: formattedText, sender: sender)
|
TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId)
|
||||||
} else {
|
} else {
|
||||||
Text("no text")
|
Text("no text")
|
||||||
.italic()
|
.italic()
|
||||||
|
@ -271,10 +272,12 @@ struct ChatItemInfoView: View {
|
||||||
var text: String
|
var text: String
|
||||||
var formattedText: [FormattedText]?
|
var formattedText: [FormattedText]?
|
||||||
var sender: String? = nil
|
var sender: String? = nil
|
||||||
|
var mentions: [String: CIMention]?
|
||||||
|
var userMemberId: String?
|
||||||
@State private var showSecrets = false
|
@State private var showSecrets = false
|
||||||
|
|
||||||
var body: some View {
|
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 {
|
struct ChatItemInfoView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ struct ChatView: View {
|
||||||
@State private var showChatInfoSheet: Bool = false
|
@State private var showChatInfoSheet: Bool = false
|
||||||
@State private var showAddMembersSheet: Bool = false
|
@State private var showAddMembersSheet: Bool = false
|
||||||
@State private var composeState = ComposeState()
|
@State private var composeState = ComposeState()
|
||||||
|
@State private var selectedRange = NSRange()
|
||||||
@State private var keyboardVisible = false
|
@State private var keyboardVisible = false
|
||||||
@State private var connectionStats: ConnectionStats?
|
@State private var connectionStats: ConnectionStats?
|
||||||
@State private var customUserProfile: Profile?
|
@State private var customUserProfile: Profile?
|
||||||
|
@ -76,6 +77,9 @@ struct ChatView: View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
chatItemsList()
|
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)
|
FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat)
|
||||||
}
|
}
|
||||||
connectingText()
|
connectingText()
|
||||||
|
@ -83,7 +87,8 @@ struct ChatView: View {
|
||||||
ComposeView(
|
ComposeView(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
composeState: $composeState,
|
composeState: $composeState,
|
||||||
keyboardVisible: $keyboardVisible
|
keyboardVisible: $keyboardVisible,
|
||||||
|
selectedRange: $selectedRange
|
||||||
)
|
)
|
||||||
.disabled(!cInfo.sendMsgEnabled)
|
.disabled(!cInfo.sendMsgEnabled)
|
||||||
} else {
|
} else {
|
||||||
|
@ -991,31 +996,37 @@ struct ChatView: View {
|
||||||
markedRead = true
|
markedRead = true
|
||||||
}
|
}
|
||||||
if let range {
|
if let range {
|
||||||
let itemIds = unreadItemIds(range)
|
let (itemIds, unreadMentions) = unreadItemIds(range)
|
||||||
if !itemIds.isEmpty {
|
if !itemIds.isEmpty {
|
||||||
waitToMarkRead {
|
waitToMarkRead {
|
||||||
await apiMarkChatItemsRead(chat.chatInfo, itemIds)
|
await apiMarkChatItemsRead(chat.chatInfo, itemIds, mentionsRead: unreadMentions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if chatItem.isRcvNew {
|
} else if chatItem.isRcvNew {
|
||||||
waitToMarkRead {
|
waitToMarkRead {
|
||||||
await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id])
|
await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
.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
|
let im = ItemsModel.shared
|
||||||
return range.compactMap { i in
|
var unreadItems: [ChatItem.ID] = []
|
||||||
if i >= 0 && i < im.reversedChatItems.count {
|
var unreadMentions: Int = 0
|
||||||
let ci = im.reversedChatItems[i]
|
|
||||||
return if ci.isRcvNew { ci.id } else { nil }
|
for i in range {
|
||||||
} else {
|
let ci = im.reversedChatItems[i]
|
||||||
return nil
|
if ci.isRcvNew {
|
||||||
|
unreadItems.append(ci.id)
|
||||||
|
if ci.meta.userMention {
|
||||||
|
unreadMentions += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (unreadItems, unreadMentions)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
|
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
|
||||||
|
@ -1227,7 +1238,7 @@ struct ChatView: View {
|
||||||
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
|
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
|
||||||
chatItemInfo = nil
|
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
|
@ObservedObject var chat: Chat
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
if let nextMode = chat.chatInfo.nextNtfMode {
|
||||||
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
Button {
|
||||||
} label: {
|
toggleNotifications(chat, enableNtfs: nextMode)
|
||||||
if chat.chatInfo.ntfsEnabled {
|
} label: {
|
||||||
Label("Mute", systemImage: "speaker.slash")
|
Label(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.icon)
|
||||||
} else {
|
|
||||||
Label("Unmute", systemImage: "speaker.wave.2")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleNotifications(_ chat: Chat, enableNtfs: Bool) {
|
func toggleNotifications(_ chat: Chat, enableNtfs: MsgFilter) {
|
||||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||||
chatSettings.enableNtfs = enableNtfs ? .all : .none
|
chatSettings.enableNtfs = enableNtfs
|
||||||
updateChatSettings(chat, chatSettings: chatSettings)
|
updateChatSettings(chat, chatSettings: chatSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,8 @@ import SimpleXChat
|
||||||
import SwiftyGif
|
import SwiftyGif
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
|
|
||||||
|
let MAX_NUMBER_OF_MENTIONS = 3
|
||||||
|
|
||||||
enum ComposePreview {
|
enum ComposePreview {
|
||||||
case noPreview
|
case noPreview
|
||||||
case linkPreview(linkPreview: LinkPreview?)
|
case linkPreview(linkPreview: LinkPreview?)
|
||||||
|
@ -19,7 +21,7 @@ enum ComposePreview {
|
||||||
case filePreview(fileName: String, file: URL)
|
case filePreview(fileName: String, file: URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ComposeContextItem {
|
enum ComposeContextItem: Equatable {
|
||||||
case noContextItem
|
case noContextItem
|
||||||
case quotedItem(chatItem: ChatItem)
|
case quotedItem(chatItem: ChatItem)
|
||||||
case editingItem(chatItem: ChatItem)
|
case editingItem(chatItem: ChatItem)
|
||||||
|
@ -39,31 +41,41 @@ struct LiveMessage {
|
||||||
var sentMsg: String?
|
var sentMsg: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typealias MentionedMembers = [String: CIMention]
|
||||||
|
|
||||||
struct ComposeState {
|
struct ComposeState {
|
||||||
var message: String
|
var message: String
|
||||||
|
var parsedMessage: [FormattedText]
|
||||||
var liveMessage: LiveMessage? = nil
|
var liveMessage: LiveMessage? = nil
|
||||||
var preview: ComposePreview
|
var preview: ComposePreview
|
||||||
var contextItem: ComposeContextItem
|
var contextItem: ComposeContextItem
|
||||||
var voiceMessageRecordingState: VoiceMessageRecordingState
|
var voiceMessageRecordingState: VoiceMessageRecordingState
|
||||||
var inProgress = false
|
var inProgress = false
|
||||||
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
||||||
|
var mentions: MentionedMembers = [:]
|
||||||
|
|
||||||
init(
|
init(
|
||||||
message: String = "",
|
message: String = "",
|
||||||
|
parsedMessage: [FormattedText] = [],
|
||||||
liveMessage: LiveMessage? = nil,
|
liveMessage: LiveMessage? = nil,
|
||||||
preview: ComposePreview = .noPreview,
|
preview: ComposePreview = .noPreview,
|
||||||
contextItem: ComposeContextItem = .noContextItem,
|
contextItem: ComposeContextItem = .noContextItem,
|
||||||
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording
|
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording,
|
||||||
|
mentions: MentionedMembers = [:]
|
||||||
) {
|
) {
|
||||||
self.message = message
|
self.message = message
|
||||||
|
self.parsedMessage = parsedMessage
|
||||||
self.liveMessage = liveMessage
|
self.liveMessage = liveMessage
|
||||||
self.preview = preview
|
self.preview = preview
|
||||||
self.contextItem = contextItem
|
self.contextItem = contextItem
|
||||||
self.voiceMessageRecordingState = voiceMessageRecordingState
|
self.voiceMessageRecordingState = voiceMessageRecordingState
|
||||||
|
self.mentions = mentions
|
||||||
}
|
}
|
||||||
|
|
||||||
init(editingItem: ChatItem) {
|
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.preview = chatItemPreview(chatItem: editingItem)
|
||||||
self.contextItem = .editingItem(chatItem: editingItem)
|
self.contextItem = .editingItem(chatItem: editingItem)
|
||||||
if let emc = editingItem.content.msgContent,
|
if let emc = editingItem.content.msgContent,
|
||||||
|
@ -72,10 +84,12 @@ struct ComposeState {
|
||||||
} else {
|
} else {
|
||||||
self.voiceMessageRecordingState = .noRecording
|
self.voiceMessageRecordingState = .noRecording
|
||||||
}
|
}
|
||||||
|
self.mentions = editingItem.mentions ?? [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) {
|
init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) {
|
||||||
self.message = ""
|
self.message = ""
|
||||||
|
self.parsedMessage = []
|
||||||
self.preview = .noPreview
|
self.preview = .noPreview
|
||||||
self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo)
|
self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo)
|
||||||
self.voiceMessageRecordingState = .noRecording
|
self.voiceMessageRecordingState = .noRecording
|
||||||
|
@ -83,20 +97,38 @@ struct ComposeState {
|
||||||
|
|
||||||
func copy(
|
func copy(
|
||||||
message: String? = nil,
|
message: String? = nil,
|
||||||
|
parsedMessage: [FormattedText]? = nil,
|
||||||
liveMessage: LiveMessage? = nil,
|
liveMessage: LiveMessage? = nil,
|
||||||
preview: ComposePreview? = nil,
|
preview: ComposePreview? = nil,
|
||||||
contextItem: ComposeContextItem? = nil,
|
contextItem: ComposeContextItem? = nil,
|
||||||
voiceMessageRecordingState: VoiceMessageRecordingState? = nil
|
voiceMessageRecordingState: VoiceMessageRecordingState? = nil,
|
||||||
|
mentions: MentionedMembers? = nil
|
||||||
) -> ComposeState {
|
) -> ComposeState {
|
||||||
ComposeState(
|
ComposeState(
|
||||||
message: message ?? self.message,
|
message: message ?? self.message,
|
||||||
|
parsedMessage: parsedMessage ?? self.parsedMessage,
|
||||||
liveMessage: liveMessage ?? self.liveMessage,
|
liveMessage: liveMessage ?? self.liveMessage,
|
||||||
preview: preview ?? self.preview,
|
preview: preview ?? self.preview,
|
||||||
contextItem: contextItem ?? self.contextItem,
|
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 {
|
var editing: Bool {
|
||||||
switch contextItem {
|
switch contextItem {
|
||||||
case .editingItem: return true
|
case .editingItem: return true
|
||||||
|
@ -293,6 +325,7 @@ struct ComposeView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@Binding var composeState: ComposeState
|
@Binding var composeState: ComposeState
|
||||||
@Binding var keyboardVisible: Bool
|
@Binding var keyboardVisible: Bool
|
||||||
|
@Binding var selectedRange: NSRange
|
||||||
|
|
||||||
@State var linkUrl: URL? = nil
|
@State var linkUrl: URL? = nil
|
||||||
@State var hasSimplexLink: Bool = false
|
@State var hasSimplexLink: Bool = false
|
||||||
|
@ -376,6 +409,7 @@ struct ComposeView: View {
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
SendMessageView(
|
SendMessageView(
|
||||||
composeState: $composeState,
|
composeState: $composeState,
|
||||||
|
selectedRange: $selectedRange,
|
||||||
sendMessage: { ttl in
|
sendMessage: { ttl in
|
||||||
sendMessage(ttl: ttl)
|
sendMessage(ttl: ttl)
|
||||||
resetLinkPreview()
|
resetLinkPreview()
|
||||||
|
@ -428,15 +462,17 @@ struct ComposeView: View {
|
||||||
.ignoresSafeArea(.all, edges: .bottom)
|
.ignoresSafeArea(.all, edges: .bottom)
|
||||||
}
|
}
|
||||||
.onChange(of: composeState.message) { msg in
|
.onChange(of: composeState.message) { msg in
|
||||||
|
let parsedMsg = parseSimpleXMarkdown(msg)
|
||||||
|
composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg))
|
||||||
if composeState.linkPreviewAllowed {
|
if composeState.linkPreviewAllowed {
|
||||||
if msg.count > 0 {
|
if msg.count > 0 {
|
||||||
showLinkPreview(msg)
|
showLinkPreview(parsedMsg)
|
||||||
} else {
|
} else {
|
||||||
resetLinkPreview()
|
resetLinkPreview()
|
||||||
hasSimplexLink = false
|
hasSimplexLink = false
|
||||||
}
|
}
|
||||||
} else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) {
|
} else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) {
|
||||||
(_, hasSimplexLink) = parseMessage(msg)
|
(_, hasSimplexLink) = getSimplexLink(parsedMsg)
|
||||||
} else {
|
} else {
|
||||||
hasSimplexLink = false
|
hasSimplexLink = false
|
||||||
}
|
}
|
||||||
|
@ -793,6 +829,7 @@ struct ComposeView: View {
|
||||||
var sent: ChatItem?
|
var sent: ChatItem?
|
||||||
let msgText = text ?? composeState.message
|
let msgText = text ?? composeState.message
|
||||||
let liveMessage = composeState.liveMessage
|
let liveMessage = composeState.liveMessage
|
||||||
|
let mentions = composeState.memberMentions
|
||||||
if !live {
|
if !live {
|
||||||
if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) }
|
if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) }
|
||||||
await sending()
|
await sending()
|
||||||
|
@ -803,7 +840,7 @@ struct ComposeView: View {
|
||||||
// Composed text is send as a reply to the last forwarded item
|
// Composed text is send as a reply to the last forwarded item
|
||||||
sent = await forwardItems(chatItems, fromChatInfo, ttl).last
|
sent = await forwardItems(chatItems, fromChatInfo, ttl).last
|
||||||
if !composeState.message.isEmpty {
|
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 {
|
} else if case let .editingItem(ci) = composeState.contextItem {
|
||||||
sent = await updateMessage(ci, live: live)
|
sent = await updateMessage(ci, live: live)
|
||||||
|
@ -819,10 +856,11 @@ struct ComposeView: View {
|
||||||
|
|
||||||
switch (composeState.preview) {
|
switch (composeState.preview) {
|
||||||
case .noPreview:
|
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:
|
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):
|
case let .mediaPreviews(media):
|
||||||
|
// TODO: CHECK THIS
|
||||||
let last = media.count - 1
|
let last = media.count - 1
|
||||||
var msgs: [ComposedMessage] = []
|
var msgs: [ComposedMessage] = []
|
||||||
if last >= 0 {
|
if last >= 0 {
|
||||||
|
@ -847,10 +885,10 @@ struct ComposeView: View {
|
||||||
case let .voicePreview(recordingFileName, duration):
|
case let .voicePreview(recordingFileName, duration):
|
||||||
stopPlayback.toggle()
|
stopPlayback.toggle()
|
||||||
let file = voiceCryptoFile(recordingFileName)
|
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):
|
case let .filePreview(_, file):
|
||||||
if let savedFile = saveFileFromURL(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,
|
type: chat.chatInfo.chatType,
|
||||||
id: chat.chatInfo.apiId,
|
id: chat.chatInfo.apiId,
|
||||||
itemId: ei.id,
|
itemId: ei.id,
|
||||||
msg: mc,
|
updatedMessage: UpdatedMessage(msgContent: mc, mentions: composeState.memberMentions),
|
||||||
live: live
|
live: live
|
||||||
)
|
)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
@ -977,9 +1015,9 @@ struct ComposeView: View {
|
||||||
return nil
|
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(
|
await send(
|
||||||
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)],
|
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)],
|
||||||
live: live,
|
live: live,
|
||||||
ttl: ttl
|
ttl: ttl
|
||||||
).first
|
).first
|
||||||
|
@ -1043,7 +1081,8 @@ struct ComposeView: View {
|
||||||
func checkLinkPreview() -> MsgContent {
|
func checkLinkPreview() -> MsgContent {
|
||||||
switch (composeState.preview) {
|
switch (composeState.preview) {
|
||||||
case let .linkPreview(linkPreview: linkPreview):
|
case let .linkPreview(linkPreview: linkPreview):
|
||||||
if let url = parseMessage(msgText).url,
|
if let parsedMsg = parseSimpleXMarkdown(msgText),
|
||||||
|
let url = getSimplexLink(parsedMsg).url,
|
||||||
let linkPreview = linkPreview,
|
let linkPreview = linkPreview,
|
||||||
url == linkPreview.uri {
|
url == linkPreview.uri {
|
||||||
return .link(text: msgText, preview: linkPreview)
|
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
|
prevLinkUrl = linkUrl
|
||||||
(linkUrl, hasSimplexLink) = parseMessage(s)
|
(linkUrl, hasSimplexLink) = getSimplexLink(parsedMsg)
|
||||||
if let url = linkUrl {
|
if let url = linkUrl {
|
||||||
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
|
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
|
||||||
pendingLinkUrl = url
|
pendingLinkUrl = url
|
||||||
|
@ -1181,8 +1220,8 @@ struct ComposeView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func parseMessage(_ msg: String) -> (url: URL?, hasSimplexLink: Bool) {
|
private func getSimplexLink(_ parsedMsg: [FormattedText]?) -> (url: URL?, hasSimplexLink: Bool) {
|
||||||
guard let parsedMsg = parseSimpleXMarkdown(msg) else { return (nil, false) }
|
guard let parsedMsg else { return (nil, false) }
|
||||||
let url: URL? = if let uri = parsedMsg.first(where: { ft in
|
let url: URL? = if let uri = parsedMsg.first(where: { ft in
|
||||||
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
|
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
|
||||||
}) {
|
}) {
|
||||||
|
@ -1234,18 +1273,21 @@ struct ComposeView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||||
@State var composeState = ComposeState(message: "hello")
|
@State var composeState = ComposeState(message: "hello")
|
||||||
|
@State var selectedRange = NSRange()
|
||||||
|
|
||||||
return Group {
|
return Group {
|
||||||
ComposeView(
|
ComposeView(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
composeState: $composeState,
|
composeState: $composeState,
|
||||||
keyboardVisible: Binding.constant(true)
|
keyboardVisible: Binding.constant(true),
|
||||||
|
selectedRange: $selectedRange
|
||||||
)
|
)
|
||||||
.environmentObject(ChatModel())
|
.environmentObject(ChatModel())
|
||||||
ComposeView(
|
ComposeView(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
composeState: $composeState,
|
composeState: $composeState,
|
||||||
keyboardVisible: Binding.constant(true)
|
keyboardVisible: Binding.constant(true),
|
||||||
|
selectedRange: $selectedRange
|
||||||
)
|
)
|
||||||
.environmentObject(ChatModel())
|
.environmentObject(ChatModel())
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,7 +71,7 @@ struct ContextItemView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func contextMsgPreview(_ contextItem: ChatItem) -> Text {
|
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 {
|
func attachment() -> Text {
|
||||||
let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) {
|
let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) {
|
||||||
|
|
|
@ -17,18 +17,19 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||||
@Binding var height: CGFloat
|
@Binding var height: CGFloat
|
||||||
@Binding var focused: Bool
|
@Binding var focused: Bool
|
||||||
@Binding var placeholder: String?
|
@Binding var placeholder: String?
|
||||||
|
@Binding var selectedRange: NSRange
|
||||||
let onImagesAdded: ([UploadContent]) -> Void
|
let onImagesAdded: ([UploadContent]) -> Void
|
||||||
|
|
||||||
private let minHeight: CGFloat = 37
|
private let minHeight: CGFloat = 37
|
||||||
|
|
||||||
private let defaultHeight: CGFloat = {
|
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)
|
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)
|
return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
func makeUIView(context: Context) -> UITextView {
|
||||||
let field = CustomUITextField(height: _height)
|
let field = CustomUITextField(parent: self, height: _height)
|
||||||
field.backgroundColor = .clear
|
field.backgroundColor = .clear
|
||||||
field.text = text
|
field.text = text
|
||||||
field.textAlignment = alignment(text)
|
field.textAlignment = alignment(text)
|
||||||
|
@ -69,6 +70,10 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||||
if castedField.placeholder != placeholder {
|
if castedField.placeholder != placeholder {
|
||||||
castedField.placeholder = placeholder
|
castedField.placeholder = placeholder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if field.selectedRange != selectedRange {
|
||||||
|
field.selectedRange = selectedRange
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateHeight(_ field: UITextView) {
|
private func updateHeight(_ field: UITextView) {
|
||||||
|
@ -99,6 +104,7 @@ private func alignment(_ text: String) -> NSTextAlignment {
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CustomUITextField: UITextView, UITextViewDelegate {
|
private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||||
|
var parent: NativeTextEditor?
|
||||||
var height: Binding<CGFloat>
|
var height: Binding<CGFloat>
|
||||||
var newHeight: CGFloat = 0
|
var newHeight: CGFloat = 0
|
||||||
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
|
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
|
||||||
|
@ -106,7 +112,8 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||||
|
|
||||||
private let placeholderLabel: UILabel = UILabel()
|
private let placeholderLabel: UILabel = UILabel()
|
||||||
|
|
||||||
init(height: Binding<CGFloat>) {
|
init(parent: NativeTextEditor?, height: Binding<CGFloat>) {
|
||||||
|
self.parent = parent
|
||||||
self.height = height
|
self.height = height
|
||||||
super.init(frame: .zero, textContainer: nil)
|
super.init(frame: .zero, textContainer: nil)
|
||||||
}
|
}
|
||||||
|
@ -232,10 +239,22 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||||
|
|
||||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
onFocusChanged(true)
|
onFocusChanged(true)
|
||||||
|
updateSelectedRange(textView)
|
||||||
}
|
}
|
||||||
|
|
||||||
func textViewDidEndEditing(_ textView: UITextView) {
|
func textViewDidEndEditing(_ textView: UITextView) {
|
||||||
onFocusChanged(false)
|
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),
|
height: Binding.constant(100),
|
||||||
focused: Binding.constant(false),
|
focused: Binding.constant(false),
|
||||||
placeholder: Binding.constant("Placeholder"),
|
placeholder: Binding.constant("Placeholder"),
|
||||||
|
selectedRange: Binding.constant(NSRange(location: 0, length: 0)),
|
||||||
onImagesAdded: { _ in }
|
onImagesAdded: { _ in }
|
||||||
)
|
)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
|
@ -13,6 +13,7 @@ private let liveMsgInterval: UInt64 = 3000_000000
|
||||||
|
|
||||||
struct SendMessageView: View {
|
struct SendMessageView: View {
|
||||||
@Binding var composeState: ComposeState
|
@Binding var composeState: ComposeState
|
||||||
|
@Binding var selectedRange: NSRange
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var sendMessage: (Int?) -> Void
|
var sendMessage: (Int?) -> Void
|
||||||
var sendLiveMessage: (() async -> Void)? = nil
|
var sendLiveMessage: (() async -> Void)? = nil
|
||||||
|
@ -62,6 +63,7 @@ struct SendMessageView: View {
|
||||||
height: $teHeight,
|
height: $teHeight,
|
||||||
focused: $keyboardVisible,
|
focused: $keyboardVisible,
|
||||||
placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
|
placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
|
||||||
|
selectedRange: $selectedRange,
|
||||||
onImagesAdded: onMediaAdded
|
onImagesAdded: onMediaAdded
|
||||||
)
|
)
|
||||||
.allowsTightening(false)
|
.allowsTightening(false)
|
||||||
|
@ -424,8 +426,10 @@ struct SendMessageView: View {
|
||||||
struct SendMessageView_Previews: PreviewProvider {
|
struct SendMessageView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
@State var composeStateNew = ComposeState()
|
@State var composeStateNew = ComposeState()
|
||||||
|
@State var selectedRange = NSRange()
|
||||||
let ci = ChatItem.getSample(1, .directSnd, .now, "hello")
|
let ci = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||||
@State var composeStateEditing = ComposeState(editingItem: ci)
|
@State var composeStateEditing = ComposeState(editingItem: ci)
|
||||||
|
@State var selectedRangeEditing = NSRange()
|
||||||
@State var sendEnabled: Bool = true
|
@State var sendEnabled: Bool = true
|
||||||
|
|
||||||
return Group {
|
return Group {
|
||||||
|
@ -434,6 +438,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
SendMessageView(
|
SendMessageView(
|
||||||
composeState: $composeStateNew,
|
composeState: $composeStateNew,
|
||||||
|
selectedRange: $selectedRange,
|
||||||
sendMessage: { _ in },
|
sendMessage: { _ in },
|
||||||
onMediaAdded: { _ in },
|
onMediaAdded: { _ in },
|
||||||
keyboardVisible: Binding.constant(true)
|
keyboardVisible: Binding.constant(true)
|
||||||
|
@ -444,6 +449,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
SendMessageView(
|
SendMessageView(
|
||||||
composeState: $composeStateEditing,
|
composeState: $composeStateEditing,
|
||||||
|
selectedRange: $selectedRangeEditing,
|
||||||
sendMessage: { _ in },
|
sendMessage: { _ in },
|
||||||
onMediaAdded: { _ in },
|
onMediaAdded: { _ in },
|
||||||
keyboardVisible: Binding.constant(true)
|
keyboardVisible: Binding.constant(true)
|
||||||
|
|
|
@ -138,12 +138,12 @@ struct GroupChatInfoView: View {
|
||||||
addMembersButton()
|
addMembersButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if members.count > 8 {
|
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
|
||||||
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
|
.padding(.leading, 8)
|
||||||
.padding(.leading, 8)
|
|
||||||
}
|
|
||||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
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)
|
MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
|
||||||
ForEach(filteredMembers) { member in
|
ForEach(filteredMembers) { member in
|
||||||
ZStack {
|
ZStack {
|
||||||
|
@ -276,7 +276,9 @@ struct GroupChatInfoView: View {
|
||||||
if groupInfo.canAddMembers {
|
if groupInfo.canAddMembers {
|
||||||
addMembersActionButton(width: buttonWidth)
|
addMembersActionButton(width: buttonWidth)
|
||||||
}
|
}
|
||||||
muteButton(width: buttonWidth)
|
if let nextNtfMode = chat.chatInfo.nextNtfMode {
|
||||||
|
muteButton(width: buttonWidth, nextNtfMode: nextNtfMode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
|
@ -324,13 +326,13 @@ struct GroupChatInfoView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func muteButton(width: CGFloat) -> some View {
|
private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
|
||||||
InfoViewButton(
|
return InfoViewButton(
|
||||||
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
image: nextNtfMode.iconFilled,
|
||||||
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
|
title: "\(nextNtfMode.text(mentions: true))",
|
||||||
width: width
|
width: width
|
||||||
) {
|
) {
|
||||||
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
toggleNotifications(chat, enableNtfs: nextNtfMode)
|
||||||
}
|
}
|
||||||
.disabled(!groupInfo.ready)
|
.disabled(!groupInfo.ready)
|
||||||
}
|
}
|
||||||
|
@ -353,11 +355,7 @@ struct GroupChatInfoView: View {
|
||||||
.onAppear {
|
.onAppear {
|
||||||
searchFocussed = false
|
searchFocussed = false
|
||||||
Task {
|
Task {
|
||||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
await chatModel.loadGroupMembers(groupInfo)
|
||||||
await MainActor.run {
|
|
||||||
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
|
|
||||||
chatModel.populateGroupMembersIndexes()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -387,7 +385,7 @@ struct GroupChatInfoView: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
memberInfo(member)
|
memberInfo(member)
|
||||||
}
|
}
|
||||||
|
|
||||||
if user {
|
if user {
|
||||||
v
|
v
|
||||||
} else if groupInfo.membership.memberRole >= .admin {
|
} else if groupInfo.membership.memberRole >= .admin {
|
||||||
|
@ -490,7 +488,7 @@ struct GroupChatInfoView: View {
|
||||||
.foregroundColor(theme.colors.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func memberInfoView(_ groupMember: GMember) -> some View {
|
private func memberInfoView(_ groupMember: GMember) -> some View {
|
||||||
GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
|
GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
|
||||||
.navigationBarHidden(false)
|
.navigationBarHidden(false)
|
||||||
|
|
234
apps/ios/Shared/Views/Chat/Group/GroupMentions.swift
Normal file
234
apps/ios/Shared/Views/Chat/Group/GroupMentions.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -58,7 +58,7 @@ struct GroupWelcomeView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func textPreview() -> some 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(minHeight: 130, alignment: .topLeading)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
|
@ -302,14 +302,14 @@ struct ChatListNavLink: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View {
|
@ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View {
|
||||||
Button {
|
if let nextMode = chat.chatInfo.nextNtfMode {
|
||||||
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
Button {
|
||||||
} label: {
|
toggleNotifications(chat, enableNtfs: nextMode)
|
||||||
if chat.chatInfo.ntfsEnabled {
|
} label: {
|
||||||
SwipeLabel(NSLocalizedString("Mute", comment: "swipe action"), systemImage: "speaker.slash.fill", inverted: oneHandUI)
|
SwipeLabel(nextMode.text(mentions: chat.chatInfo.hasMentions), systemImage: nextMode.iconFilled, inverted: oneHandUI)
|
||||||
} else {
|
|
||||||
SwipeLabel(NSLocalizedString("Unmute", comment: "swipe action"), systemImage: "speaker.wave.2.fill", inverted: oneHandUI)
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -480,7 +480,7 @@ struct ChatListView: View {
|
||||||
switch chatTagsModel.activeFilter {
|
switch chatTagsModel.activeFilter {
|
||||||
case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)
|
case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)
|
||||||
case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true
|
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
|
case .none: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -188,12 +188,14 @@ struct ChatPreviewView: View {
|
||||||
|
|
||||||
private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View {
|
private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View {
|
||||||
ZStack(alignment: .topTrailing) {
|
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
|
let t = text
|
||||||
.lineLimit(userFont <= .xxxLarge ? 2 : 1)
|
.lineLimit(userFont <= .xxxLarge ? 2 : 1)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
.padding(.leading, hasFilePreview ? 0 : 8)
|
.padding(.leading, hasFilePreview ? 0 : 8)
|
||||||
.padding(.trailing, hasFilePreview ? 38 : 36)
|
.padding(.trailing, mentionWidth + (hasFilePreview ? 38 : 36))
|
||||||
.offset(x: hasFilePreview ? -2 : 0)
|
.offset(x: hasFilePreview ? -2 : 0)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
if !showChatPreviews && !draft {
|
if !showChatPreviews && !draft {
|
||||||
|
@ -208,19 +210,34 @@ struct ChatPreviewView: View {
|
||||||
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
|
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
|
||||||
let s = chat.chatStats
|
let s = chat.chatStats
|
||||||
if s.unreadCount > 0 || s.unreadChat {
|
if s.unreadCount > 0 || s.unreadChat {
|
||||||
unreadCountText(s.unreadCount)
|
let mentionColor = mentionColor(chat)
|
||||||
.font(userFont <= .xxxLarge ? .caption : .caption2)
|
HStack(alignment: .center, spacing: 2) {
|
||||||
.foregroundColor(.white)
|
if s.unreadMentions > 0 && s.unreadCount > 1 {
|
||||||
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
|
Text("\(MENTION_START)")
|
||||||
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
|
.font(userFont <= .xxxLarge ? .body : .callout)
|
||||||
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
|
.foregroundColor(mentionColor)
|
||||||
.cornerRadius(dynamicSize(userFont).unreadCorner)
|
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
|
||||||
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
|
.cornerRadius(dynamicSize(userFont).unreadCorner)
|
||||||
Image(systemName: "speaker.slash.fill")
|
.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()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
|
.frame(width: iconSize, height: iconSize)
|
||||||
.foregroundColor(theme.colors.secondary)
|
.foregroundColor(iconColor)
|
||||||
} else if chat.chatInfo.chatSettings?.favorite ?? false {
|
} else if chat.chatInfo.chatSettings?.favorite ?? false {
|
||||||
Image(systemName: "star.fill")
|
Image(systemName: "star.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
|
@ -232,12 +249,20 @@ struct ChatPreviewView: View {
|
||||||
Color.clear.frame(width: 0)
|
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 {
|
private func messageDraft(_ draft: ComposeState) -> Text {
|
||||||
let msg = draft.message
|
let msg = draft.message
|
||||||
return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary)
|
return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary)
|
||||||
+ attachment()
|
+ 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 {
|
func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text {
|
||||||
Text(Image(systemName: s)).foregroundColor(color) + textSpace
|
Text(Image(systemName: s)).foregroundColor(color) + textSpace
|
||||||
|
@ -256,7 +281,7 @@ struct ChatPreviewView: View {
|
||||||
func chatItemPreview(_ cItem: ChatItem) -> Text {
|
func chatItemPreview(_ cItem: ChatItem) -> Text {
|
||||||
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
|
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
|
||||||
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
|
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;
|
// 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
|
// can be refactored into a single function if functions calling these are changed to return same type
|
||||||
|
|
|
@ -191,11 +191,7 @@ struct AddGroupView: View {
|
||||||
profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on))
|
profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on))
|
||||||
let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile)
|
let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile)
|
||||||
Task {
|
Task {
|
||||||
let groupMembers = await apiListMembers(gInfo.groupId)
|
await m.loadGroupMembers(gInfo)
|
||||||
await MainActor.run {
|
|
||||||
m.groupMembers = groupMembers.map { GMember.init($0) }
|
|
||||||
m.populateGroupMembersIndexes()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
|
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
|
||||||
m.addChat(c)
|
m.addChat(c)
|
||||||
|
|
|
@ -18,6 +18,7 @@ struct TerminalView: View {
|
||||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||||
@State var composeState: ComposeState = ComposeState()
|
@State var composeState: ComposeState = ComposeState()
|
||||||
|
@State var selectedRange = NSRange()
|
||||||
@State private var keyboardVisible = false
|
@State private var keyboardVisible = false
|
||||||
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
||||||
@State private var terminalItem: TerminalItem?
|
@State private var terminalItem: TerminalItem?
|
||||||
|
@ -96,6 +97,7 @@ struct TerminalView: View {
|
||||||
|
|
||||||
SendMessageView(
|
SendMessageView(
|
||||||
composeState: $composeState,
|
composeState: $composeState,
|
||||||
|
selectedRange: $selectedRange,
|
||||||
sendMessage: { _ in consoleSendMessage() },
|
sendMessage: { _ in consoleSendMessage() },
|
||||||
showVoiceMessageButton: false,
|
showVoiceMessageButton: false,
|
||||||
onMediaAdded: { _ in },
|
onMediaAdded: { _ in },
|
||||||
|
|
|
@ -789,7 +789,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)?
|
||||||
if let file = cItem.autoReceiveFile() {
|
if let file = cItem.autoReceiveFile() {
|
||||||
cItem = autoReceiveFile(file) ?? cItem
|
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)
|
return (chatItem.chatId, ntf)
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -205,6 +205,7 @@
|
||||||
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
|
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
|
||||||
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
|
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
|
||||||
B70A39732D24090D00E80A5F /* TagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70A39722D24090D00E80A5F /* TagListView.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 */; };
|
B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */; };
|
||||||
B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; };
|
B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; };
|
||||||
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1096,6 +1098,7 @@
|
||||||
5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */,
|
5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */,
|
||||||
6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */,
|
6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */,
|
||||||
1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */,
|
1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */,
|
||||||
|
B70CE9E52D4BE5930080F36D /* GroupMentions.swift */,
|
||||||
);
|
);
|
||||||
path = Group;
|
path = Group;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1452,6 +1455,7 @@
|
||||||
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */,
|
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */,
|
||||||
5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */,
|
5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */,
|
||||||
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */,
|
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */,
|
||||||
|
B70CE9E62D4BE5930080F36D /* GroupMentions.swift in Sources */,
|
||||||
CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */,
|
CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */,
|
||||||
64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */,
|
64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */,
|
||||||
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */,
|
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */,
|
||||||
|
|
|
@ -52,7 +52,7 @@ public enum ChatCommand {
|
||||||
case apiReorderChatTags(tagIds: [Int64])
|
case apiReorderChatTags(tagIds: [Int64])
|
||||||
case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
|
case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
|
||||||
case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String)
|
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 apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode)
|
||||||
case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64])
|
case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64])
|
||||||
case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction)
|
case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction)
|
||||||
|
@ -226,7 +226,7 @@ public enum ChatCommand {
|
||||||
return "/_create *\(noteFolderId) json \(msgs)"
|
return "/_create *\(noteFolderId) json \(msgs)"
|
||||||
case let .apiReportMessage(groupId, chatItemId, reportReason, reportText):
|
case let .apiReportMessage(groupId, chatItemId, reportReason, reportText):
|
||||||
return "/_report #\(groupId) \(chatItemId) reason=\(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 .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 .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))"
|
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?
|
public var fileSource: CryptoFile?
|
||||||
var quotedItemId: Int64?
|
var quotedItemId: Int64?
|
||||||
public var msgContent: MsgContent
|
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.fileSource = fileSource
|
||||||
self.quotedItemId = quotedItemId
|
self.quotedItemId = quotedItemId
|
||||||
self.msgContent = msgContent
|
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 none
|
||||||
case all
|
case all
|
||||||
case mentions
|
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 {
|
public struct UserMsgReceiptSettings: Codable {
|
||||||
|
|
|
@ -1447,9 +1447,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
|
||||||
return .other
|
return .other
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var ntfsEnabled: Bool {
|
public func ntfsEnabled(chatItem: ChatItem) -> Bool {
|
||||||
self.chatSettings?.enableNtfs == .all
|
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? {
|
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]? {
|
public var chatTags: [Int64]? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .direct(contact): return contact.chatTags
|
case let .direct(contact): return contact.chatTags
|
||||||
|
@ -1559,14 +1575,16 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike {
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ChatStats: Decodable, Hashable {
|
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.unreadCount = unreadCount
|
||||||
|
self.unreadMentions = unreadMentions
|
||||||
self.reportsCount = reportsCount
|
self.reportsCount = reportsCount
|
||||||
self.minUnreadItemId = minUnreadItemId
|
self.minUnreadItemId = minUnreadItemId
|
||||||
self.unreadChat = unreadChat
|
self.unreadChat = unreadChat
|
||||||
}
|
}
|
||||||
|
|
||||||
public var unreadCount: Int = 0
|
public var unreadCount: Int = 0
|
||||||
|
public var unreadMentions: Int = 0
|
||||||
// actual only via getChats() and getChat(.initial), otherwise, zero
|
// actual only via getChats() and getChat(.initial), otherwise, zero
|
||||||
public var reportsCount: Int = 0
|
public var reportsCount: Int = 0
|
||||||
public var minUnreadItemId: Int64 = 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)
|
? String.localizedStringWithFormat(NSLocalizedString("Past member %@", comment: "past/unknown group member"), name)
|
||||||
: 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 {
|
public var memberActive: Bool {
|
||||||
switch memberStatus {
|
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 struct ACIReaction: Decodable, Hashable {
|
||||||
public var chatInfo: ChatInfo
|
public var chatInfo: ChatInfo
|
||||||
public var chatReaction: CIReaction
|
public var chatReaction: CIReaction
|
||||||
|
@ -2410,11 +2460,12 @@ public struct CIReaction: Decodable, Hashable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ChatItem: Identifiable, 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.chatDir = chatDir
|
||||||
self.meta = meta
|
self.meta = meta
|
||||||
self.content = content
|
self.content = content
|
||||||
self.formattedText = formattedText
|
self.formattedText = formattedText
|
||||||
|
self.mentions = mentions
|
||||||
self.quotedItem = quotedItem
|
self.quotedItem = quotedItem
|
||||||
self.reactions = reactions
|
self.reactions = reactions
|
||||||
self.file = file
|
self.file = file
|
||||||
|
@ -2424,6 +2475,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||||
public var meta: CIMeta
|
public var meta: CIMeta
|
||||||
public var content: CIContent
|
public var content: CIContent
|
||||||
public var formattedText: [FormattedText]?
|
public var formattedText: [FormattedText]?
|
||||||
|
public var mentions: [String: CIMention]?
|
||||||
public var quotedItem: CIQuote?
|
public var quotedItem: CIQuote?
|
||||||
public var reactions: [CIReactionCount]
|
public var reactions: [CIReactionCount]
|
||||||
public var file: CIFile?
|
public var file: CIFile?
|
||||||
|
@ -2432,7 +2484,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||||
public var isLiveDummy: Bool = false
|
public var isLiveDummy: Bool = false
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
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 }
|
public var id: Int64 { meta.itemId }
|
||||||
|
@ -2743,6 +2795,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||||
itemDeleted: nil,
|
itemDeleted: nil,
|
||||||
itemEdited: false,
|
itemEdited: false,
|
||||||
itemLive: false,
|
itemLive: false,
|
||||||
|
userMention: false,
|
||||||
deletable: false,
|
deletable: false,
|
||||||
editable: false
|
editable: false
|
||||||
),
|
),
|
||||||
|
@ -2765,6 +2818,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||||
itemDeleted: nil,
|
itemDeleted: nil,
|
||||||
itemEdited: false,
|
itemEdited: false,
|
||||||
itemLive: false,
|
itemLive: false,
|
||||||
|
userMention: false,
|
||||||
deletable: false,
|
deletable: false,
|
||||||
editable: false
|
editable: false
|
||||||
),
|
),
|
||||||
|
@ -2787,6 +2841,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||||
itemDeleted: nil,
|
itemDeleted: nil,
|
||||||
itemEdited: false,
|
itemEdited: false,
|
||||||
itemLive: true,
|
itemLive: true,
|
||||||
|
userMention: false,
|
||||||
deletable: false,
|
deletable: false,
|
||||||
editable: false
|
editable: false
|
||||||
),
|
),
|
||||||
|
@ -2860,6 +2915,7 @@ public struct CIMeta: Decodable, Hashable {
|
||||||
public var itemEdited: Bool
|
public var itemEdited: Bool
|
||||||
public var itemTimed: CITimed?
|
public var itemTimed: CITimed?
|
||||||
public var itemLive: Bool?
|
public var itemLive: Bool?
|
||||||
|
public var userMention: Bool
|
||||||
public var deletable: Bool
|
public var deletable: Bool
|
||||||
public var editable: Bool
|
public var editable: Bool
|
||||||
|
|
||||||
|
@ -2884,6 +2940,7 @@ public struct CIMeta: Decodable, Hashable {
|
||||||
itemDeleted: itemDeleted,
|
itemDeleted: itemDeleted,
|
||||||
itemEdited: itemEdited,
|
itemEdited: itemEdited,
|
||||||
itemLive: itemLive,
|
itemLive: itemLive,
|
||||||
|
userMention: false,
|
||||||
deletable: deletable,
|
deletable: deletable,
|
||||||
editable: editable
|
editable: editable
|
||||||
)
|
)
|
||||||
|
@ -2900,6 +2957,7 @@ public struct CIMeta: Decodable, Hashable {
|
||||||
itemDeleted: nil,
|
itemDeleted: nil,
|
||||||
itemEdited: false,
|
itemEdited: false,
|
||||||
itemLive: false,
|
itemLive: false,
|
||||||
|
userMention: false,
|
||||||
deletable: false,
|
deletable: false,
|
||||||
editable: false
|
editable: false
|
||||||
)
|
)
|
||||||
|
@ -3921,6 +3979,12 @@ public struct FormattedText: Decodable, Hashable {
|
||||||
public var text: String
|
public var text: String
|
||||||
public var format: Format?
|
public var format: Format?
|
||||||
|
|
||||||
|
public static func plain(_ text: String) -> [FormattedText] {
|
||||||
|
text.isEmpty
|
||||||
|
? []
|
||||||
|
: [FormattedText(text: text, format: nil)]
|
||||||
|
}
|
||||||
|
|
||||||
public var isSecret: Bool {
|
public var isSecret: Bool {
|
||||||
if case .secret = format { true } else { false }
|
if case .secret = format { true } else { false }
|
||||||
}
|
}
|
||||||
|
@ -3935,6 +3999,7 @@ public enum Format: Decodable, Equatable, Hashable {
|
||||||
case colored(color: FormatColor)
|
case colored(color: FormatColor)
|
||||||
case uri
|
case uri
|
||||||
case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String])
|
case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String])
|
||||||
|
case mention(memberName: String)
|
||||||
case email
|
case email
|
||||||
case phone
|
case phone
|
||||||
|
|
||||||
|
|
|
@ -40,7 +40,7 @@ public struct ErrorAlert: Error {
|
||||||
self = if let chatResponse = error as? ChatResponse {
|
self = if let chatResponse = error as? ChatResponse {
|
||||||
ErrorAlert(chatResponse)
|
ErrorAlert(chatResponse)
|
||||||
} else {
|
} else {
|
||||||
ErrorAlert(LocalizedStringKey(error.localizedDescription))
|
ErrorAlert("\(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
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)
|
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.Connection -> IO [UserInfo]
|
||||||
getUsersInfo db = getUsers db >>= mapM getUserInfo
|
getUsersInfo db = getUsers db >>= mapM getUserInfo
|
||||||
where
|
where
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue