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