cometchat-ios-customization

SkillDev tools

Brand and restyle the CometChat iOS UI Kit — theme colours, typography, per-component styles, localisation and sounds. Use for any 'make it match our brand' or 'change how it looks' request. Triggers: 'change cometchat colors ios', 'brand the chat ui swift', 'dark mode cometchat ios', 'custom font c

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the cometchat-ios-customization skill

What this skill tells your AI

The instructions your AI receives, as published by cometchat/cometchat-skills in skills/cometchat-ios-customization/SKILL.md and read by ahel’s review.

Ground truth: CometChatUIKitSwift ~> 5 (+ CometChatCallsSDK ~> 5) — Pods/SPM .swiftinterface + ui-kit/ios. Official docs: https://www.cometchat.com/docs/ui-kit/ios/overview · Docs MCP: claude mcp add --transport http cometchat-docs https://www.cometchat.com/docs/mcp (or fetch the URL directly without MCP). Verify symbols against the installed package/source before relying on them.

Purpose

This skill teaches advanced customization of CometChat iOS UI Kit — custom views, message templates, text formatters, event listeners, and request builder filters.


1. Custom Views in CometChatConversations

Custom List Item View

Replace the entire list item with a custom view:

let conversations = CometChatConversations()

conversations.set(listItemView: { (conversation: Conversation) -> UIView in
    let customView = UIView()
    customView.backgroundColor = .secondarySystemBackground

    let nameLabel = UILabel()
    nameLabel.font = .boldSystemFont(ofSize: 16)

    if let user = conversation.conversationWith as? User {
        nameLabel.text = user.name
    } else if let group = conversation.conversationWith as? Group {
        nameLabel.text = group.name
    }

    customView.addSubview(nameLabel)
    nameLabel.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        nameLabel.leadingAnchor.constraint(equalTo: customView.leadingAnchor, constant: 16),
        nameLabel.centerYAnchor.constraint(equalTo: customView.centerYAnchor)
    ])

    return customView
})

Custom Leading View (Avatar Area)

conversations.set(leadingView: { (conversation: Conversation) -> UIView in
    let avatarView = UIView()
    avatarView.backgroundColor = .systemBlue
    avatarView.layer.cornerRadius = 24

    let initialsLabel = UILabel()
    initialsLabel.textColor = .white
    initialsLabel.font = .boldSystemFont(ofSize: 14)
    initialsLabel.textAlignment = .center

    if let user = conversation.conversationWith as? User {
        initialsLabel.text = String(user.name?.prefix(2).uppercased() ?? "")
    } else if let group = conversation.conversationWith as? Group {
        initialsLabel.text = String(group.name?.prefix(2).uppercased() ?? "")
    }

    avatarView.addSubview(initialsLabel)
    initialsLabel.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        initialsLabel.centerXAnchor.constraint(equalTo: avatarView.centerXAnchor),
        initialsLabel.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor),
        avatarView.widthAnchor.constraint(equalToConstant: 48),
        avatarView.heightAnchor.constraint(equalToConstant: 48)
    ])

    return avatarView
})

Custom Title View

conversations.set(titleView: { (conversation: Conversation) -> UIView in
    let stackView = UIStackView()
    stackView.axis = .horizontal
    stackView.spacing = 8

    let nameLabel = UILabel()
    nameLabel.font = .boldSystemFont(ofSize: 16)

    let verifiedBadge = UIImageView(image: UIImage(systemName: "checkmark.seal.fill"))
    verifiedBadge.tintColor = .systemBlue
    verifiedBadge.isHidden = true

    if let user = conversation.conversationWith as? User {
        nameLabel.text = user.name
        // Show badge for verified users (custom metadata)
        if let metadata = user.metadata, metadata["verified"] as? Bool == true {
            verifiedBadge.isHidden = false
        }
    } else if let group = conversation.conversationWith as? Group {
        nameLabel.text = group.name
    }

    stackView.addArrangedSubview(nameLabel)
    stackView.addArrangedSubview(verifiedBadge)

    return stackView
})

Custom Subtitle View

conversations.set(subtitleView: { (conversation: Conversation) -> UIView in
    let label = UILabel()
    label.font = .systemFont(ofSize: 14)
    label.textColor = .secondaryLabel

    if let lastMessage = conversation.lastMessage as? TextMessage {
        label.text = lastMessage.text
    } else if let lastMessage = conversation.lastMessage as? MediaMessage {
        switch lastMessage.messageType {
        case .image:
            label.text = "📷 Photo"
        case .video:
            label.text = "🎥 Video"
        case .audio:
            label.text = "🎵 Audio"
        case .file:
            label.text = "📎 File"
        default:
            label.text = "Attachment"
        }
    } else {
        label.text = "No messages yet"
    }

    return label
})

Custom Tail View

conversations.set(trailView: { (conversation: Conversation) -> UIView in
    let stackView = UIStackView()
    stackView.axis = .vertical
    stackView.alignment = .trailing
    stackView.spacing = 4

    // Time label
    let timeLabel = UILabel()
    timeLabel.font = .systemFont(ofSize: 12)
    timeLabel.textColor = .tertiaryLabel

    if let timestamp = conversation.lastMessage?.sentAt {
        let date = Date(timeIntervalSince1970: TimeInterval(timestamp))
        let formatter = DateFormatter()
        formatter.dateFormat = "h:mm a"
        timeLabel.text = formatter.string(from: date)
    }

    // Unread badge
    let badge = UILabel()
    badge.font = .boldSystemFont(ofSize: 12)
    badge.textColor = .white
    badge.backgroundColor = .systemBlue
    badge.textAlignment = .center
    badge.layer.cornerRadius = 10
    badge.clipsToBounds = true

    let unreadCount = conversation.unreadMessageCount
    badge.isHidden = unreadCount == 0
    badge.text = unreadCount > 99 ? "99+" : "\(unreadCount)"

    stackView.addArrangedSubview(timeLabel)
    stackView.addArrangedSubview(badge)

    NSLayoutConstraint.activate([
        badge.widthAnchor.constraint(greaterThanOrEqualToConstant: 20),
        badge.heightAnchor.constraint(equalToConstant: 20)
    ])

    return stackView
})

2. Custom Swipe Actions

Custom Options

conversations.set(options: { (conversation: Conversation?) -> [CometChatConversationOption] in
    guard let conversation = conversation else { return [] }
    var options: [CometChatConversationOption] = []

    // CometChatConversationOption.init takes NO `id`; the click handler is the
    // `onClick` initializer parameter (not a settable property) and its closure is
    // (User?, Int, CometChatConversationOption, UIViewController?) -> Void.
    // Pin conversation
    let pinOption = CometChatConversationOption(
        title: "Pin",
        titleColor: nil,
        icon: UIImage(systemName: "pin.fill"),
        titleFont: nil,
        iconTint: .white,
        backgroundColor: .systemYellow,
        onClick: { user, index, option, controller in
            print("Pin conversation")
        }
    )
    options.append(pinOption)

    // Mute conversation
    let muteOption = CometChatConversationOption(
        title: "Mute",
        titleColor: nil,
        icon: UIImage(systemName: "bell.slash.fill"),
        titleFont: nil,
        iconTint: .white,
        backgroundColor: .systemGray,
        onClick: { user, index, option, controller in
            print("Mute conversation")
        }
    )
    options.append(muteOption)

    // Delete conversation
    let deleteOption = CometChatConversationOption(
        title: "Delete",
        titleColor: nil,
        icon: UIImage(systemName: "trash.fill"),
        titleFont: nil,
        iconTint: .white,
        backgroundColor: .systemRed,
        onClick: { user, index, option, controller in
            print("Delete conversation")
        }
    )
    options.append(deleteOption)

    return options
})

Add Options (Keep Default + Add Custom)

conversations.add(options: { (conversation: Conversation?) -> [CometChatConversationOption] in
    let archiveOption = CometChatConversationOption(
        title: "Archive",
        titleColor: nil,
        icon: UIImage(systemName: "archivebox.fill"),
        titleFont: nil,
        iconTint: .white,
        backgroundColor: .systemPurple,
        onClick: { user, index, option, controller in
            print("Archive conversation")
        }
    )

    return [archiveOption]
})

3. Custom Message Templates

Creating a Custom Message Template

⚠️ CometChatMessageTemplate is a struct with ONE memberwise initializer — there is NO CometChatMessageTemplate(type:category:) convenience init, and the label order is category first, then type. The init requires ALL of these labels: category:type:contentView:bubbleView:headerView:footerView:bottomView:options: (the trailing statusInfoView: / replyView: default to nil). Pass nil for any view closure you don't need — you cannot omit the label. Verified: CometChatMessageTemplate.swift:25-47.

let messageList = CometChatMessageList()

// Get default templates — the argument is required (pass nil for defaults).
// Starting from this list (not []) keeps every built-in bubble.
// CometChatUIKit.getDataSource() → CometChatUIKit.swift:287; getAllMessageTemplates(additionalConfiguration:) → DataSource.swift:63
var templates = CometChatUIKit.getDataSource().getAllMessageTemplates(additionalConfiguration: nil)

// Create custom template for a specific message type.
// ALL labels below are required by the only initializer (CometChatMessageTemplate.swift:25).
// `category` comes BEFORE `type`. Each view closure is (BaseMessage?, MessageBubbleAlignment, UIViewController?) -> UIView?.
let customTemplate = CometChatMessageTemplate(
    category: "custom",
    type: "custom_poll",

    // Custom content view — the inner bubble content
    contentView: { message, alignment, controller in
        guard let customMessage = message as? CustomMessage,
              let data = customMessage.customData else {
            return UIView()
        }
        let pollView = PollBubbleView()
        pollView.configure(with: data)
        return pollView
    },

    // Custom bubble view (wraps content). alignment is MessageBubbleAlignment (.left/.right/.center — UIKitConstants.swift:85).
    bubbleView: { message, alignment, controller in
        let bubble = UIView()
        bubble.backgroundColor = alignment == .right
            ? CometChatTheme.primaryColor.withAlphaComponent(0.1)
            : UIColor.secondarySystemBackground
        bubble.layer.cornerRadius = 12
        return bubble
    },

    // Custom header view (above the bubble)
    headerView: { message, alignment, controller in
        let label = UILabel()
        label.text = "📊 Poll"
        label.font = .boldSystemFont(ofSize: 12)
        label.textColor = .secondaryLabel
        return label
    },

    // Custom footer view (below the bubble)
    footerView: { message, alignment, controller in
        let label = UILabel()
        label.text = "Tap to vote"
        label.font = .systemFont(ofSize: 11)
        label.textColor = .tertiaryLabel
        return label
    },

    // bottomView — pass nil if unused (label still required)
    bottomView: nil,

    // Custom options (long press menu). Closure is (BaseMessage?, Group?, UIViewController?) -> [CometChatMessageOption]?
    options: { message, group, controller in
        let viewResultsOption = CometChatMessageOption(
            id: "view_results",
            title: "View Results",
            icon: UIImage(systemName: "chart.bar.fill"),
            onItemClick: { message in
                // Show poll results — closure receives the BaseMessage? only
            }
        )
        return [viewResultsOption]
    }
)

// APPEND to the default templates (do NOT start from []) so every built-in bubble survives.
templates.append(customTemplate)

// set(templates:) → CometChatMessageList + Properties.swift:54 (replaces the list, so we passed the merged array).
// add(templates:) (line 63) merges into the existing list if you prefer.
messageList.set(templates: templates)

Custom Poll Bubble View Example

class PollBubbleView: UIView {

    private let questionLabel = UILabel()
    private let optionsStack = UIStackView()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupUI()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupUI()
    }

    private func setupUI() {
        questionLabel.font = .boldSystemFont(ofSize: 16)
        questionLabel.numberOfLines = 0

        optionsStack.axis = .vertical
        optionsStack.spacing = 8

        addSubview(questionLabel)
        addSubview(optionsStack)

        questionLabel.translatesAutoresizingMaskIntoConstraints = false
        optionsStack.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            questionLabel.topAnchor.constraint(equalTo: topAnchor, constant: 12),
            questionLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
            questionLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),

            optionsStack.topAnchor.constraint(equalTo: questionLabel.bottomAnchor, constant: 12),
            optionsStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
            optionsStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
            optionsStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12)
        ])
    }

    func configure(with data: [String: Any]) {
        questionLabel.text = data["question"] as? String

        optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }

        if let options = data["options"] as? [String] {
            for option in options {
                let button = UIButton(type: .system)
                button.setTitle(option, for: .normal)
                button.backgroundColor = .systemGray5
                button.layer.cornerRadius = 8
                button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12)
                optionsStack.addArrangedSubview(button)
            }
        }
    }
}

4. Text Formatters

Creating a Custom Text Formatter

class HashtagFormatter: CometChatTextFormatter {

    override func getRegex() -> String {
        return "#[a-zA-Z0-9_]+"
    }

    override func prepareMessageString(
        baseMessage: BaseMessage,
        regexString: String,
        alignment: MessageBubbleAlignment,
        formattingType: FormattingType
    ) -> NSAttributedString {

        guard let textMessage = baseMessage as? TextMessage,
              let text = textMessage.text else {
            return NSAttributedString()
        }

        let attributedString = NSMutableAttributedString(string: text)

        // Default text attributes
        let defaultAttributes: [NSAttributedString.Key: Any] = [
            .font: CometChatTypography.Body.regular,
            .foregroundColor: alignment == .right ? UIColor.white : UIColor.label
        ]
        attributedString.addAttributes(defaultAttributes, range: NSRange(location: 0, length: text.count))

        // Highlight hashtags
        let regex = try? NSRegularExpression(pattern: regexString, options: [])
        let matches = regex?.matches(in: text, options: [], range: NSRange(location: 0, length: text.count)) ?? []

        for match in matches {
            let hashtagAttributes: [NSAttributedString.Key: Any] = [
                .foregroundColor: UIColor.systemBlue,
                .font: CometChatTypography.Body.medium
            ]
            attributedString.addAttributes(hashtagAttributes, range: match.range)
        }

        return attributedString
    }

    override func onTextTapped(
        baseMessage: BaseMessage,
        tappedText: String,
        controller: UIViewController?
    ) {
        // Handle hashtag tap
        print("Hashtag tapped: \(tappedText)")
        // Navigate to hashtag search, etc.
    }
}

// Usage
let messageList = CometChatMessageList()
messageList.textFormatter = [
    CometChatMentionsFormatter(),  // Built-in mentions
    HashtagFormatter()              // Custom hashtags
]

URL Formatter with Custom Preview

class CustomURLFormatter: CometChatTextFormatter {

    override func getRegex() -> String {
        return "https?://[^\\s]+"
    }

    override func prepareMessageString(
        baseMessage: BaseMessage,
        regexString: String,
        alignment: MessageBubbleAlignment,
        formattingType: FormattingType
    ) -> NSAttributedString {

        guard let textMessage = baseMessage as? TextMessage,
              let text = textMessage.text else {
            return NSAttributedString()
        }

        let attributedString = NSMutableAttributedString(string: text)

        let regex = try? NSRegularExpression(pattern: regexString, options: [])
        let matches = regex?.matches(in: text, options: [], range: NSRange(location: 0, length: text.count)) ?? []

        for match in matches {
            let urlAttributes: [NSAttributedString.Key: Any] = [
                .foregroundColor: UIColor.systemBlue,
                .underlineStyle: NSUnderlineStyle.single.rawValue
            ]
            attributedString.addAttributes(urlAttributes, range: match.range)
        }

        return attributedString
    }

    override func onTextTapped(
        baseMessage: BaseMessage,
        tappedText: String,
        controller: UIViewController?
    ) {
        if let url = URL(string: tappedText) {
            UIApplication.shared.open(url)
        }
    }
}

5. Event Listeners

Message Events

import CometChatSDK

class MessageEventListener: CometChatMessageEventListener {

    func ccMessageSent(message: BaseMessage, status: MessageStatus) {
        switch status {
        case .inProgress:
            print("Message sending...")
        case .success:
            print("Message sent successfully")
        case .error:
            print("Message failed to send")
        }
    }

    func ccMessageEdited(message: BaseMessage, status: MessageStatus) {
        print("Message edited: \(message.id)")
    }

    func ccMessageDeleted(message: BaseMessage) {
        print("Message deleted: \(message.id)")
    }

    func ccMessageRead(message: BaseMessage) {
        print("Message read: \(message.id)")
    }

    func ccLiveReaction(reaction: TransientMessage) {
        print("Live reaction received")
    }
}

// Register listener
let listener = MessageEventListener()
CometChatMessageEvents.addListener("message-listener", listener)

// Remove listener when done
CometChatMessageEvents.removeListener("message-listener")

User Events

class UserEventListener: CometChatUserEventListener {

    func ccUserBlocked(user: User) {
        print("User blocked: \(user.name ?? "")")
    }

    func ccUserUnblocked(user: User) {
        print("User unblocked: \(user.name ?? "")")
    }
}

// Register
CometChatUserEvents.addListener("user-listener", UserEventListener())

Group Events

class GroupEventListener: CometChatGroupEventListener {

    func ccGroupCreated(group: Group) {
        print("Group created: \(group.name ?? "")")
    }

    func ccGroupDeleted(group: Group) {
        print("Group deleted: \(group.name ?? "")")
    }

    func ccGroupMemberAdded(messages: [ActionMessage], usersAdded: [User], groupAddedIn: Group, addedBy: User) {
        print("Members added to group")
    }

    func ccGroupMemberKicked(action: ActionMessage, kickedUser: User, kickedBy: User, kickedFrom: Group) {
        print("Member kicked from group")
    }

    func ccGroupMemberBanned(action: ActionMessage, bannedUser: User, bannedBy: User, bannedFrom: Group) {
        print("Member banned from group")
    }

    func ccOwnershipChanged(group: Group, newOwner: GroupMember) {
        print("Group ownership changed")
    }
}

// Register
CometChatGroupEvents.addListener("group-listener", GroupEventListener())

Call Events

class CallEventListener: CometChatCallEventListener {

    func ccCallAccepted(call: Call) {
        print("Call accepted")
    }

    func ccCallRejected(call: Call) {
        print("Call rejected")
    }

    func ccCallEnded(call: Call) {
        print("Call ended")
    }

    func ccOutgoingCall(call: Call) {
        print("Outgoing call initiated")
    }

    func onCallInitiated(call: Call) {   // CometChatCallEventListener has onCallInitiated, not ccCallInitiated
        print("Call initiated")
    }
}

// Register
CometChatCallEvents.addListener("call-listener", CallEventListener())

6. Request Builder Filters

Filter Conversations

let conversations = CometChatConversations()

// Only show user conversations (no groups)
// ConversationRequestBuilder exposes setConversationType(conversationType:), not set(conversationType:)
conversations.set(conversationsRequestBuilder: ConversationRequest.ConversationRequestBuilder()
    .setConversationType(conversationType: .user)
    .set(limit: 30)
)

Note: ConversationRequestBuilder has no tags filter — its only methods are set(limit:), setConversationType(conversationType:), and build(). To filter by tags, fetch and filter the resulting conversations client-side.

Filter Messages

let messageList = CometChatMessageList()

// Only show text and image messages
messageList.set(messagesRequestBuilder: MessagesRequest.MessageRequestBuilder()
    .set(uid: user.uid ?? "")
    .set(types: [MessageTypeConstants.text, MessageTypeConstants.image])
    .set(limit: 30)
)

// Only show messages from a specific time range
let startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date())!
messageList.set(messagesRequestBuilder: MessagesRequest.MessageRequestBuilder()
    .set(uid: user.uid ?? "")
    .set(timeStamp: Int(startDate.timeIntervalSince1970))
    .set(limit: 50)
)

// Hide deleted messages
messageList.set(messagesRequestBuilder: MessagesRequest.MessageRequestBuilder()
    .set(uid: user.uid ?? "")
    .hideDeletedMessages(hide: true)
    .set(limit: 30)
)

Filter Users

let users = CometChatUsers()

// Only show users with specific role
users.set(usersRequestBuilder: UsersRequest.UsersRequestBuilder()
    .set(roles: ["admin", "moderator"])
    .set(limit: 30)
)

// Only show friends
users.set(usersRequestBuilder: UsersRequest.UsersRequestBuilder()
    .friendsOnly(true)
    .set(limit: 30)
)

// Search users
users.set(usersRequestBuilder: UsersRequest.UsersRequestBuilder()
    .set(searchKeyword: "john")
    .set(limit: 30)
)

Filter Groups

let groups = CometChatGroups()

// Only show joined groups
groups.set(groupsRequestBuilder: GroupsRequest.GroupsRequestBuilder()
    .set(joinedOnly: true)
    .set(limit: 30)
)

// Only show public groups
groups.set(groupsRequestBuilder: GroupsRequest.GroupsRequestBuilder()
    .set(groupType: .public)
    .set(limit: 30)
)

// Search groups
groups.set(groupsRequestBuilder: GroupsRequest.GroupsRequestBuilder()
    .set(searchKeyWord: "team")
    .set(limit: 30)
)

7. DataSource Decorator Pattern

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
105
Forks
2
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
cometchat-ios-customization
Source
github.com/cometchat/cometchat-skills