Implement user mentions in comments with autocomplete, notifications, custom rendering, and comprehensive mention management
Component | Description | Purpose |
---|---|---|
Detection | Identify @ symbols and potential mentions | Trigger autocomplete |
Search | Find users matching typed text | Provide selection options |
Metadata | Store mention position and user info | Enable custom rendering |
Rendering | Display mentions with custom styling | Visual differentiation |
Notifications | Alert mentioned users | Drive engagement |
import AmitySDK
struct MentionData {
let userId: String
let displayName: String
let index: Int
let length: Int
}
class CommentMentionManager {
private let commentRepository: AmityCommentRepository
private let userRepository: AmityUserRepository
init(client: AmityClient) {
self.commentRepository = AmityCommentRepository(client: client)
self.userRepository = AmityUserRepository(client: client)
}
// Create comment with mentions
func createCommentWithMentions(
referenceId: String,
referenceType: AmityCommentReferenceType,
text: String,
mentions: [MentionData],
parentId: String? = nil,
completion: @escaping (Result<AmityComment, Error>) -> Void
) {
// Extract user IDs from mentions
let mentionUserIds = mentions.map { $0.userId }
// Create mention metadata for proper rendering
let mentionMetadata = createMentionMetadata(from: mentions, in: text)
let builder = AmityCommentCreationDataBuilder()
builder.setText(text)
builder.setMentionUsers(mentionUserIds)
builder.setMetadata(mentionMetadata)
builder.setParentId(parentId)
commentRepository.createComment(
for: referenceId,
referenceType: referenceType,
with: builder.build()
).observeOnce { result in
completion(result)
}
}
// Create mention metadata for custom rendering
private func createMentionMetadata(from mentions: [MentionData], in text: String) -> [String: Any] {
let mentionArray = mentions.map { mention in
return [
"userId": mention.userId,
"displayName": mention.displayName,
"type": "user",
"index": mention.index,
"length": mention.length
]
}
return [
"mentions": mentionArray,
"originalText": text
]
}
// Search users for autocomplete
func searchUsers(
query: String,
completion: @escaping (Result<[AmityUser], Error>) -> Void
) {
let userQuery = AmityUserQuery.Builder()
.setDisplayName(query)
.setLimit(10)
.build()
userRepository.getUsers(with: userQuery).observeOnce { result in
switch result {
case .success(let userCollection):
let users = Array(userCollection.allObjects())
completion(.success(users))
case .failure(let error):
completion(.failure(error))
}
}
}
// Update comment with mentions
func updateCommentWithMentions(
commentId: String,
newText: String,
mentions: [MentionData],
completion: @escaping (Result<AmityComment, Error>) -> Void
) {
let mentionUserIds = mentions.map { $0.userId }
let mentionMetadata = createMentionMetadata(from: mentions, in: newText)
let editor = AmityCommentEditor(client: commentRepository.client)
editor.edit(commentId: commentId)
.setText(newText)
.setMentionUsers(mentionUserIds)
.setMetadata(mentionMetadata)
.update { result in
completion(result)
}
}
// Remove mentions from comment
func removeMentionsFromComment(
commentId: String,
newText: String,
completion: @escaping (Result<AmityComment, Error>) -> Void
) {
let editor = AmityCommentEditor(client: commentRepository.client)
editor.edit(commentId: commentId)
.setText(newText)
.setMentionUsers([]) // Empty array removes all mentions
.setMetadata([:]) // Empty metadata
.update { result in
completion(result)
}
}
}
// Custom text view with mention support
class MentionTextView: UITextView {
var onMentionTriggered: ((String) -> Void)?
var onUserSelected: ((AmityUser) -> Void)?
private var currentMentionRange: NSRange?
private var mentionData: [MentionData] = []
override func awakeFromNib() {
super.awakeFromNib()
delegate = self
}
// Parse text for mentions and apply styling
func applyMentionStyling() {
let attributedText = NSMutableAttributedString(string: text)
// Reset styling
attributedText.addAttribute(.foregroundColor, value: UIColor.label, range: NSRange(location: 0, length: text.count))
// Apply mention styling
for mention in mentionData {
let range = NSRange(location: mention.index, length: mention.length)
attributedText.addAttributes([
.foregroundColor: UIColor.systemBlue,
.font: UIFont.boldSystemFont(ofSize: font?.pointSize ?? 16)
], range: range)
}
self.attributedText = attributedText
}
// Insert mention at current position
func insertMention(_ user: AmityUser) {
guard let mentionRange = currentMentionRange else { return }
let mentionText = "@\(user.displayName ?? user.userId)"
let newText = (text as NSString).replacingCharacters(in: mentionRange, with: mentionText)
// Update mention data
let mention = MentionData(
userId: user.userId,
displayName: user.displayName ?? user.userId,
index: mentionRange.location,
length: mentionText.count
)
mentionData.append(mention)
text = newText
applyMentionStyling()
currentMentionRange = nil
}
// Get current mentions for comment creation
func getCurrentMentions() -> [MentionData] {
return mentionData
}
}
extension MentionTextView: UITextViewDelegate {
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
// Detect @ symbol for mention triggering
if text == "@" {
currentMentionRange = NSRange(location: range.location, length: 1)
onMentionTriggered?("")
return true
}
// Handle mention typing
if let mentionRange = currentMentionRange, text != " " && text != "\n" {
let currentQuery = (textView.text as NSString).substring(with: NSRange(
location: mentionRange.location + 1,
length: range.location - mentionRange.location - 1
)) + text
onMentionTriggered?(currentQuery)
}
return true
}
}
// Usage in view controller
class CommentComposeViewController: UIViewController {
@IBOutlet weak var mentionTextView: MentionTextView!
@IBOutlet weak var userSuggestionsTableView: UITableView!
private let mentionManager: CommentMentionManager
private var suggestedUsers: [AmityUser] = []
private let postId: String
init(postId: String, client: AmityClient) {
self.postId = postId
self.mentionManager = CommentMentionManager(client: client)
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
setupMentionHandling()
}
private func setupMentionHandling() {
mentionTextView.onMentionTriggered = { [weak self] query in
self?.searchUsersForMention(query: query)
}
mentionTextView.onUserSelected = { [weak self] user in
self?.mentionTextView.insertMention(user)
self?.hideSuggestions()
}
}
private func searchUsersForMention(query: String) {
guard !query.isEmpty else {
hideSuggestions()
return
}
mentionManager.searchUsers(query: query) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let users):
self?.showUserSuggestions(users)
case .failure(let error):
print("Failed to search users: \(error)")
self?.hideSuggestions()
}
}
}
}
private func showUserSuggestions(_ users: [AmityUser]) {
suggestedUsers = users
userSuggestionsTableView.reloadData()
userSuggestionsTableView.isHidden = false
}
private func hideSuggestions() {
userSuggestionsTableView.isHidden = true
suggestedUsers.removeAll()
}
@IBAction func postComment() {
let text = mentionTextView.text ?? ""
let mentions = mentionTextView.getCurrentMentions()
mentionManager.createCommentWithMentions(
referenceId: postId,
referenceType: .post,
text: text,
mentions: mentions
) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success:
self?.navigationController?.popViewController(animated: true)
case .failure(let error):
self?.showError("Failed to post comment: \(error.localizedDescription)")
}
}
}
}
}
Custom Mention Autocomplete
interface MentionAutocomplete {
searchUsers(query: string, options?: SearchOptions): Promise<User[]>;
getRankedSuggestions(query: string, context: MentionContext): Promise<User[]>;
cacheRecentMentions(userId: string): void;
}
class AdvancedMentionAutocomplete implements MentionAutocomplete {
private recentMentions = new Map<string, Date>();
private userCache = new Map<string, User>();
async searchUsers(query: string, options: SearchOptions = {}): Promise<User[]> {
const {
limit = 10,
includeRecent = true,
fuzzySearch = true,
contextUsers = []
} = options;
// Search with multiple strategies
const results = await Promise.all([
this.exactMatch(query),
fuzzySearch ? this.fuzzyMatch(query) : Promise.resolve([]),
includeRecent ? this.getRecentMentions() : Promise.resolve([]),
this.searchFromContext(query, contextUsers)
]);
// Merge and rank results
const allUsers = this.mergeAndDeduplicateUsers(results.flat());
return this.rankUsers(allUsers, query).slice(0, limit);
}
private async exactMatch(query: string): Promise<User[]> {
return SocialPlus.searchUsers({
displayName: query,
exactMatch: true
});
}
private async fuzzyMatch(query: string): Promise<User[]> {
const users = await SocialPlus.searchUsers({
displayName: query,
fuzzyMatch: true
});
// Apply fuzzy search scoring
return users.map(user => ({
...user,
score: this.calculateFuzzyScore(query, user.displayName || user.userId)
})).filter(user => user.score > 0.3);
}
private rankUsers(users: User[], query: string): User[] {
return users.sort((a, b) => {
// Prioritize recent mentions
const aRecent = this.recentMentions.has(a.userId) ? 1000 : 0;
const bRecent = this.recentMentions.has(b.userId) ? 1000 : 0;
// Calculate relevance score
const aScore = this.calculateRelevanceScore(query, a) + aRecent;
const bScore = this.calculateRelevanceScore(query, b) + bRecent;
return bScore - aScore;
});
}
}
Rich Mention Rendering
class RichMentionRenderer {
func renderMentionInAttributedString(
_ mention: MentionData,
in attributedString: NSMutableAttributedString,
with context: RenderingContext
) {
let range = NSRange(location: mention.index, length: mention.length)
// Create custom mention attachment
let mentionAttachment = MentionTextAttachment()
mentionAttachment.user = mention.user
mentionAttachment.bounds = CGRect(x: 0, y: -2, width: 20, height: 16)
// Style the mention text
attributedString.addAttributes([
.foregroundColor: UIColor.systemBlue,
.backgroundColor: UIColor.systemBlue.withAlphaComponent(0.1),
.font: UIFont.boldSystemFont(ofSize: context.fontSize),
.link: URL(string: "mention://\(mention.userId)")!,
.attachment: mentionAttachment
], range: range)
// Add custom interaction handling
attributedString.addAttribute(
.init("mentionData"),
value: mention,
range: range
)
}
func createHoverCard(for user: AmityUser) -> UIView {
let card = UIView()
card.backgroundColor = .systemBackground
card.layer.cornerRadius = 8
card.layer.shadowOpacity = 0.1
card.layer.shadowRadius = 4
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = 12
stackView.alignment = .center
// Avatar
let avatarView = UIImageView()
avatarView.widthAnchor.constraint(equalToConstant: 40).isActive = true
avatarView.heightAnchor.constraint(equalToConstant: 40).isActive = true
avatarView.layer.cornerRadius = 20
avatarView.clipsToBounds = true
if let avatarUrl = user.avatarUrl {
avatarView.loadImage(from: avatarUrl)
} else {
avatarView.backgroundColor = .systemGray4
avatarView.contentMode = .center
}
// User info
let infoStack = UIStackView()
infoStack.axis = .vertical
infoStack.spacing = 2
let nameLabel = UILabel()
nameLabel.text = user.displayName
nameLabel.font = .boldSystemFont(ofSize: 16)
let usernameLabel = UILabel()
usernameLabel.text = "@\(user.userId)"
usernameLabel.font = .systemFont(ofSize: 14)
usernameLabel.textColor = .secondaryLabel
infoStack.addArrangedSubview(nameLabel)
infoStack.addArrangedSubview(usernameLabel)
stackView.addArrangedSubview(avatarView)
stackView.addArrangedSubview(infoStack)
card.addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
stackView.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
stackView.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
stackView.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12)
])
return card
}
}
Mention Notifications
class MentionNotificationManager(private val context: Context) {
fun sendMentionNotifications(
comment: AmityComment,
mentionedUsers: List<String>
) {
mentionedUsers.forEach { userId ->
sendNotificationToUser(userId, comment)
}
}
private fun sendNotificationToUser(userId: String, comment: AmityComment) {
// Create rich notification
val notification = NotificationCompat.Builder(context, MENTION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_mention)
.setContentTitle("You were mentioned")
.setContentText("${comment.creator?.displayName} mentioned you in a comment")
.setStyle(createBigTextStyle(comment))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setColor(ContextCompat.getColor(context, R.color.mention_color))
.addAction(createReplyAction(comment))
.addAction(createViewAction(comment))
.build()
val notificationId = generateNotificationId(userId, comment.commentId)
NotificationManagerCompat.from(context).notify(notificationId, notification)
// Track mention analytics
trackMentionNotification(userId, comment)
}
private fun createBigTextStyle(comment: AmityComment): NotificationCompat.Style {
val text = when (comment.dataType) {
AmityDataType.TEXT -> (comment.data as? AmityCommentTextData)?.text
AmityDataType.IMAGE -> "📷 ${(comment.data as? AmityCommentImageData)?.caption ?: "Image comment"}"
else -> "Comment"
}
return NotificationCompat.BigTextStyle()
.bigText("${comment.creator?.displayName}: $text")
.setSummaryText("Tap to reply or view")
}
private fun createReplyAction(comment: AmityComment): NotificationCompat.Action {
val replyIntent = createReplyIntent(comment)
val replyPendingIntent = PendingIntent.getActivity(
context,
0,
replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Action.Builder(
R.drawable.ic_reply,
"Reply",
replyPendingIntent
).build()
}
private fun trackMentionNotification(userId: String, comment: AmityComment) {
AnalyticsManager.track("mention_notification_sent", mapOf(
"mentioned_user_id" to userId,
"comment_id" to comment.commentId,
"post_id" to comment.referenceId,
"mention_count" to comment.mentionUsers?.size,
"comment_type" to comment.dataType.name
))
}
}
interface Mention {
userId: string;
displayName: string;
type: 'user' | 'channel' | 'custom';
index: number; // Position in text
length: number; // Length of mention text
metadata?: {
avatar?: string;
verified?: boolean;
customData?: Record<string, any>;
};
}
interface MentionMetadata {
mentions: Mention[];
originalText: string;
renderingHints?: {
showAvatars: boolean;
customStyling: Record<string, any>;
};
}
interface Comment {
commentId: string;
text: string;
mentionUsers: string[]; // Array of mentioned user IDs
metadata: MentionMetadata; // Rich mention data
// ...other comment properties
}
Error Type | Description | Recommended Action |
---|---|---|
USER_NOT_FOUND | Mentioned user doesn’t exist | Remove mention or show placeholder |
PERMISSION_DENIED | Cannot mention user | Show permission error message |
MENTION_LIMIT_EXCEEDED | Too many mentions in comment | Enforce mention limits |
INVALID_MENTION_FORMAT | Malformed mention data | Validate and sanitize mentions |
NETWORK_ERROR | Failed to search users | Implement retry with offline cache |
Team Collaboration Comments
class TeamMentionSystem {
private teamMembers: User[] = [];
async initializeTeamContext(teamId: string): Promise<void> {
this.teamMembers = await SocialPlus.getTeamMembers(teamId);
}
async createTeamComment(
content: string,
mentions: Mention[],
priority: 'low' | 'medium' | 'high' = 'medium'
): Promise<Comment> {
// Validate mentions are team members
const validMentions = mentions.filter(mention =>
this.teamMembers.some(member => member.userId === mention.userId)
);
// Add priority metadata
const metadata = {
mentions: validMentions,
originalText: content,
priority,
teamId: this.teamId,
notificationSettings: {
urgentNotification: priority === 'high',
emailDigest: priority !== 'low'
}
};
return SocialPlus.createComment({
text: content,
mentionUsers: validMentions.map(m => m.userId),
metadata,
referenceType: 'team_discussion'
});
}
}
Social Media Engagement
Customer Support Mentions
class SupportMentionSystem {
private val supportAgents = mutableMapOf<String, SupportAgent>()
private val departmentRouting = mapOf(
"billing" to listOf("agent_billing_1", "agent_billing_2"),
"technical" to listOf("agent_tech_1", "agent_tech_2"),
"general" to listOf("agent_general_1", "agent_general_2")
)
fun createSupportComment(
ticketId: String,
text: String,
mentions: List<MentionData>,
department: String? = null,
priority: SupportPriority = SupportPriority.NORMAL
) {
// Auto-route mentions based on department
val routedMentions = if (mentions.isEmpty() && department != null) {
getAvailableAgents(department).take(1).map { agent ->
MentionData(
userId = agent.userId,
displayName = agent.displayName,
index = text.length,
length = 0
)
}
} else {
mentions
}
val enhancedText = if (routedMentions.isNotEmpty() && mentions.isEmpty()) {
"$text ${routedMentions.joinToString(" ") { "@${it.displayName}" }}"
} else {
text
}
val metadata = mapOf(
"mentions" to routedMentions.map { mention ->
mapOf(
"userId" to mention.userId,
"displayName" to mention.displayName,
"type" to "support_agent",
"department" to (supportAgents[mention.userId]?.department ?: "general"),
"autoRouted" to (mentions.isEmpty())
)
},
"supportMetadata" to mapOf(
"ticketId" to ticketId,
"priority" to priority.name,
"department" to department,
"escalationLevel" to if (priority == SupportPriority.URGENT) 1 else 0
)
)
commentRepository.createComment(
referenceId = ticketId,
referenceType = AmityCommentReferenceType.CUSTOM,
text = enhancedText,
mentionUsers = routedMentions.map { it.userId },
metadata = metadata
) { result ->
handleSupportCommentResult(result, routedMentions, priority)
}
}
private fun getAvailableAgents(department: String): List<SupportAgent> {
return departmentRouting[department]
?.mapNotNull { supportAgents[it] }
?.filter { it.isOnline && it.availableCapacity > 0 }
?.sortedBy { it.currentWorkload }
?: emptyList()
}
}