Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 9 additions & 24 deletions Sources/ExyteChat/Views/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,12 +303,15 @@ public struct ChatView<MessageContent: View, InputViewContent: View, MenuAction:
} label: {
theme.images.scrollToBottom
.frame(width: 40, height: 40)
.circleBackground(theme.colors.messageFriendBG)
.background(
Circle()
.fill(colorScheme == .dark ? theme.colors.messageFriendBG : Color(red: 0xEF/255.0, green: 0xF5/255.0, blue: 0xF9/255.0))
)
.foregroundStyle(theme.colors.sendButtonBackground)
.shadow(color: .primary.opacity(0.1), radius: 2, y: 1)
}
.padding(.trailing, MessageView.horizontalScreenEdgePadding)
.padding(.bottom, 8)
.padding(.trailing, MessageView.horizontalScreenEdgePadding + 4)
.padding(.bottom, 24)
}
}

Expand Down Expand Up @@ -388,27 +391,9 @@ public struct ChatView<MessageContent: View, InputViewContent: View, MenuAction:
}

var inputView: some View {
Group {
if let inputViewBuilder = inputViewBuilder {
inputViewBuilder($inputViewModel.text, inputViewModel.attachments, inputViewModel.state, .message, inputViewModel.inputViewAction()) {
globalFocusState.focus = nil
}
} else {
InputView(
viewModel: inputViewModel,
inputFieldId: viewModel.inputFieldId,
style: .message,
availableInputs: availableInputs,
messageStyler: messageStyler,
recorderSettings: recorderSettings,
localization: localization
)
}
}
.sizeGetter($inputViewSize)
.environmentObject(globalFocusState)
.onAppear(perform: inputViewModel.onStart)
.onDisappear(perform: inputViewModel.onStop)
// Replace default input view with transparent spacer for custom input
Color.clear
.frame(height: 30)
}

func messageMenu(_ row: MessageRow) -> some View {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ struct MessageMenu<MainButton: View, ActionEnum: MessageMenuAction>: View {

/// The max height for the menu
/// - Note: menus that exceed this value will be placed in a ScrollView
let maxMenuHeight: CGFloat = 200
let maxMenuHeight: CGFloat = 350

/// The vertical spacing between the main three components in out VStack (ReactionSelection, Message and Menu)
let verticalSpacing:CGFloat = 0
Expand Down
8 changes: 4 additions & 4 deletions Sources/ExyteChat/Views/MessageView/MessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -297,19 +297,19 @@ struct MessageView: View {
}
timeView
}
.padding(.vertical, 8)
.padding(.vertical, 6)
case .vstack:
VStack(alignment: .trailing, spacing: 4) {
messageView
timeView
}
.padding(.vertical, 8)
.padding(.vertical, 6)
case .overlay:
messageView
.padding(.vertical, 8)
.padding(.vertical, 6)
.overlay(alignment: .bottomTrailing) {
timeView
.padding(.vertical, 8)
.padding(.vertical, 6)
}
}
}
Expand Down
229 changes: 206 additions & 23 deletions Sources/ExyteChat/Views/UIList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ struct UIList<MessageContent: View, InputView: View>: UIViewRepresentable {
tableView.scrollsToTop = false
tableView.isScrollEnabled = isScrollEnabled
tableView.keyboardDismissMode = keyboardDismissMode
// Add 20px top content inset for spacing (becomes bottom due to 180° rotation)
tableView.contentInset = UIEdgeInsets(top: 28, left: 0, bottom: 0, right: 0)

NotificationCenter.default.addObserver(forName: .onScrollToBottom, object: nil, queue: nil) { _ in
DispatchQueue.main.async {
Expand Down Expand Up @@ -117,14 +119,68 @@ struct UIList<MessageContent: View, InputView: View>: UIViewRepresentable {
}
return
}

// PERFORMANCE FIX: Fast path for single message addition (most common case)
let oldTotalRows = coordinator.sections.reduce(0) { $0 + $1.rows.count }
let newTotalRows = sections.reduce(0) { $0 + $1.rows.count }

if newTotalRows == oldTotalRows + 1 && sections.count >= coordinator.sections.count {
if sections.count == coordinator.sections.count {
// New message in existing section
if sections[0].rows.count == coordinator.sections[0].rows.count + 1 {
coordinator.sections = sections
if let lastSection = sections.last {
coordinator.paginationTargetIndexPath = IndexPath(row: lastSection.rows.count - 1, section: sections.count - 1)
}

tableView.beginUpdates()
tableView.insertRows(at: [IndexPath(row: 0, section: 0)], with: .top)
tableView.endUpdates()

if isScrolledToBottom {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if !coordinator.sections.isEmpty {
tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .bottom, animated: true)
}
}
}

if !isScrollEnabled {
tableContentHeight = tableView.contentSize.height
}
return
}
} else if sections.count == coordinator.sections.count + 1 {
// New date section created
coordinator.sections = sections
if let lastSection = sections.last {
coordinator.paginationTargetIndexPath = IndexPath(row: lastSection.rows.count - 1, section: sections.count - 1)
}

tableView.beginUpdates()
tableView.insertSections([0], with: .top)
tableView.endUpdates()

if isScrolledToBottom {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if !coordinator.sections.isEmpty {
tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .bottom, animated: true)
}
}
}

if !isScrollEnabled {
tableContentHeight = tableView.contentSize.height
}
return
}
}

if let lastSection = sections.last {
coordinator.paginationTargetIndexPath = IndexPath(row: lastSection.rows.count - 1, section: sections.count - 1)
}

let prevSections = coordinator.sections
//print("0 whole sections:", runID, "\n")
//print("whole previous:\n", formatSections(prevSections), "\n")
let splitInfo = await performSplitInBackground(prevSections, sections)
await applyUpdatesToTable(tableView, splitInfo: splitInfo) {
coordinator.sections = $0
Expand Down Expand Up @@ -571,34 +627,161 @@ struct UIList<MessageContent: View, InputView: View>: UIViewRepresentable {
tableViewCell.backgroundColor = UIColor(mainBackgroundColor)

let row = sections[indexPath.section].rows[indexPath.row]
tableViewCell.contentConfiguration = UIHostingConfiguration {
ChatMessageView(
viewModel: viewModel, messageBuilder: messageBuilder, row: row, chatType: type,
avatarSize: avatarSize, tapAvatarClosure: tapAvatarClosure,
messageStyler: messageStyler, shouldShowLinkPreview: shouldShowLinkPreview,
isDisplayingMessageMenu: false, showMessageTimeView: showMessageTimeView,
messageLinkPreviewLimit: messageLinkPreviewLimit, messageFont: messageFont
)
.transition(.scale)
.background(MessageMenuPreferenceViewSetter(id: row.id))
.rotationEffect(Angle(degrees: (type == .conversation ? 180 : 0)))
.applyIf(showMessageMenuOnLongPress) {
$0.simultaneousGesture(
TapGesture().onEnded { } // add empty tap to prevent iOS17 scroll breaking bug (drag on cells stops working)

// Check if this is a system message (e.g., "User joined the clan")
if row.message.user.type == .system {
tableViewCell.contentConfiguration = UIHostingConfiguration {
systemMessageView(for: row.message)
.rotationEffect(Angle(degrees: (type == .conversation ? 180 : 0)))
}
.minSize(width: 0, height: 0)
.margins(.all, 0)
} else {
tableViewCell.contentConfiguration = UIHostingConfiguration {
ChatMessageView(
viewModel: viewModel, messageBuilder: messageBuilder, row: row, chatType: type,
avatarSize: avatarSize, tapAvatarClosure: tapAvatarClosure,
messageStyler: messageStyler, shouldShowLinkPreview: shouldShowLinkPreview,
isDisplayingMessageMenu: false, showMessageTimeView: showMessageTimeView,
messageLinkPreviewLimit: messageLinkPreviewLimit, messageFont: messageFont
)
.onLongPressGesture {
// Trigger haptic feedback
self.impactGenerator.impactOccurred()
// Launch the message menu
self.viewModel.messageMenuRow = row
.transition(.scale)
.background(MessageMenuPreferenceViewSetter(id: row.id))
.rotationEffect(Angle(degrees: (type == .conversation ? 180 : 0)))
.applyIf(showMessageMenuOnLongPress) {
$0.simultaneousGesture(
TapGesture().onEnded { } // add empty tap to prevent iOS17 scroll breaking bug (drag on cells stops working)
)
.onLongPressGesture(minimumDuration: 0.2) {
// Trigger haptic feedback
self.impactGenerator.impactOccurred()
// Launch the message menu
self.viewModel.messageMenuRow = row
}
}
}
.minSize(width: 0, height: 0)
.margins(.all, 0)
}
.minSize(width: 0, height: 0)
.margins(.all, 0)

return tableViewCell
}

// System message view builder (centered, no bubble, like date headers)
@ViewBuilder
func systemMessageView(for message: Message) -> some View {
let parsedMessage = parseSystemMessage(message.text)

HStack(spacing: 0) {
Spacer(minLength: 0)

if parsedMessage.hasUsername {
// System message with username highlighting - allow wrapping but keep words together
(Text(parsedMessage.username)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Color(red: 0, green: 0x78/255.0, blue: 1.0)) // #0078FF solid color
+
Text(parsedMessage.action)
.font(.system(size: 14))
.foregroundColor(.gray)
+
(parsedMessage.hasSecondUsername && parsedMessage.secondUsername != nil ?
Text(parsedMessage.secondUsername!)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Color(red: 0, green: 0x78/255.0, blue: 1.0)) // #0078FF solid color
: Text("")
)
+
(parsedMessage.remainingText != nil ?
Text(parsedMessage.remainingText!)
.font(.system(size: 14))
.foregroundColor(.gray)
: Text("")
))
.multilineTextAlignment(.center)
} else {
// Regular system message without username
Text(message.text)
.font(.system(size: 14))
.foregroundColor(.gray)
.multilineTextAlignment(.center)
}

Spacer(minLength: 0)
}
.padding(.vertical, 8)
.padding(.horizontal, 16)
}

// Parse system messages to extract username and action
private func parseSystemMessage(_ text: String) -> (username: String, action: String, hasUsername: Bool, secondUsername: String?, hasSecondUsername: Bool, remainingText: String?) {
print("🔍 EXYTE PARSING: '\(text)'")

// Special handling for promotion/demotion messages: "Actor promoted/demoted Target to Role"
if let promotedRange = text.range(of: " promoted ") {
let actor = String(text[..<promotedRange.lowerBound])
let afterPromoted = String(text[promotedRange.upperBound...])
print("🔍 EXYTE PROMOTED: actor='\(actor)', afterPromoted='\(afterPromoted)'")

// Find " to " to extract the target username and role
if let toRange = afterPromoted.range(of: " to ") {
let target = String(afterPromoted[..<toRange.lowerBound])
let role = String(afterPromoted[toRange.lowerBound...]) // Include " to RoleName"
print("🔍 EXYTE PROMOTED RESULT: actor='\(actor)', target='\(target)', role='\(role)'")

return (actor, " promoted ", !actor.isEmpty, target, !target.isEmpty, role)
}
}

if let demotedRange = text.range(of: " demoted ") {
let actor = String(text[..<demotedRange.lowerBound])
let afterDemoted = String(text[demotedRange.upperBound...])
print("🔍 EXYTE DEMOTED: actor='\(actor)', afterDemoted='\(afterDemoted)'")

// Find " to " to extract the target username and role
if let toRange = afterDemoted.range(of: " to ") {
let target = String(afterDemoted[..<toRange.lowerBound])
let role = String(afterDemoted[toRange.lowerBound...]) // Include " to RoleName"
print("🔍 EXYTE DEMOTED RESULT: actor='\(actor)', target='\(target)', role='\(role)'")

return (actor, " demoted ", !actor.isEmpty, target, !target.isEmpty, role)
}
}

// Standard single-username patterns
let patterns = [
" joined the clan",
" left the clan",
" was promoted",
" was demoted",
" was kicked",
" was removed from the group",
" created the clan",
" accepted an invitation and joined the clan",
" accepted a clan invite",
" left the clan. Clan disbanded.",
" is already in a clan",
" has reached the maximum of",
" is already a member"
]

for pattern in patterns {
if let range = text.range(of: pattern) {
let username = String(text[..<range.lowerBound])
let action = String(text[range.lowerBound...])

// Only highlight if there's actually a username
let hasUsername = !username.isEmpty &&
!username.lowercased().contains("a member") &&
!username.lowercased().contains("member") &&
username.trimmingCharacters(in: .whitespaces).count > 0

return (username, action, hasUsername, nil, false, nil)
}
}

return ("", text, false, nil, false, nil)
}

func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let paginationHandler = self.paginationHandler, let paginationTargetIndexPath, indexPath == paginationTargetIndexPath else {
Expand Down
Loading