github.com/status-im/status-go@v1.1.0/protocol/messenger_mention.go (about) 1 package protocol 2 3 // this is a reimplementation of the mention feature in status-react 4 // reference implementation: https://github.com/status-im/status-react/blob/972347963498fc4a2bb8f85541e79ed0541698da/src/status_im/chat/models/mentions.cljs#L1 5 import ( 6 "encoding/json" 7 "fmt" 8 "regexp" 9 "strings" 10 "sync" 11 "unicode" 12 "unicode/utf8" 13 14 "go.uber.org/zap" 15 16 "github.com/status-im/status-go/logutils" 17 18 "github.com/status-im/status-go/api/multiformat" 19 "github.com/status-im/status-go/protocol/common" 20 ) 21 22 const ( 23 endingChars = `[\s\.,;:]` 24 25 charAtSign = "@" 26 charQuote = ">" 27 charNewline = "\n" 28 charAsterisk = "*" 29 charUnderscore = "_" 30 charTilde = "~" 31 charCodeBlock = "`" 32 33 intUnknown = -1 34 35 suggestionsLimit = 10 36 ) 37 38 var ( 39 specialCharsRegex = regexp.MustCompile("[@~\\\\*_\n>`]{1}") 40 endingCharsRegex = regexp.MustCompile(endingChars) 41 wordRegex = regexp.MustCompile("^[\\w\\d\\-]*" + endingChars + "|[\\S]+") 42 ) 43 44 type specialCharLocation struct { 45 Index int 46 Value string 47 } 48 49 type atSignIndex struct { 50 Pending []int 51 Checked []int 52 } 53 54 type styleTag struct { 55 Len int 56 Idx int 57 } 58 59 type textMeta struct { 60 atSign *atSignIndex 61 styleTagMap map[string]*styleTag 62 quoteIndex *int 63 newlineIndexes []int 64 } 65 66 type searchablePhrase struct { 67 originalName string 68 phrase string 69 } 70 71 type MentionableUser struct { 72 *Contact 73 74 searchablePhrases []searchablePhrase 75 76 Key string // a unique identifier of a mentionable user 77 Match string 78 SearchedText string 79 } 80 81 func (c *MentionableUser) MarshalJSON() ([]byte, error) { 82 compressedKey, err := multiformat.SerializeLegacyKey(c.ID) 83 if err != nil { 84 return nil, err 85 } 86 87 type MentionableUserJSON struct { 88 PrimaryName string `json:"primaryName"` 89 SecondaryName string `json:"secondaryName"` 90 ENSVerified bool `json:"ensVerified"` 91 Added bool `json:"added"` 92 DisplayName string `json:"displayName"` 93 Key string `json:"key"` 94 Match string `json:"match"` 95 SearchedText string `json:"searchedText"` 96 ID string `json:"id"` 97 CompressedKey string `json:"compressedKey,omitempty"` 98 } 99 100 contactJSON := MentionableUserJSON{ 101 PrimaryName: c.PrimaryName(), 102 SecondaryName: c.SecondaryName(), 103 ENSVerified: c.ENSVerified, 104 Added: c.added(), 105 DisplayName: c.GetDisplayName(), 106 Key: c.Key, 107 Match: c.Match, 108 SearchedText: c.SearchedText, 109 ID: c.ID, 110 CompressedKey: compressedKey, 111 } 112 113 return json.Marshal(contactJSON) 114 } 115 116 func (c *MentionableUser) GetDisplayName() string { 117 if c.ENSVerified && c.EnsName != "" { 118 return c.EnsName 119 } 120 if c.DisplayName != "" { 121 return c.DisplayName 122 } 123 if c.PrimaryName() != "" { 124 return c.PrimaryName() 125 } 126 return c.Alias 127 } 128 129 type SegmentType int 130 131 const ( 132 Text SegmentType = iota 133 Mention 134 ) 135 136 type InputSegment struct { 137 Type SegmentType `json:"type"` 138 Value string `json:"value"` 139 } 140 141 type MentionState struct { 142 AtSignIdx int 143 AtIdxs []*AtIndexEntry 144 MentionEnd int 145 PreviousText string 146 NewText string 147 Start int 148 End int 149 operation textOperation 150 } 151 152 func (ms *MentionState) String() string { 153 atIdxsStr := "" 154 for i, entry := range ms.AtIdxs { 155 if i > 0 { 156 atIdxsStr += ", " 157 } 158 atIdxsStr += fmt.Sprintf("%+v", entry) 159 } 160 return fmt.Sprintf("MentionState{AtSignIdx: %d, AtIdxs: [%s], MentionEnd: %d, PreviousText: %q, NewText: %s, Start: %d, End: %d}", 161 ms.AtSignIdx, atIdxsStr, ms.MentionEnd, ms.PreviousText, ms.NewText, ms.Start, ms.End) 162 } 163 164 type ChatMentionContext struct { 165 ChatID string 166 InputSegments []InputSegment 167 MentionSuggestions map[string]*MentionableUser 168 MentionState *MentionState 169 PreviousText string // user input text before the last change 170 NewText string 171 172 CallID uint64 173 mu *sync.Mutex 174 LatestCallID uint64 175 } 176 177 func NewChatMentionContext(chatID string) *ChatMentionContext { 178 return &ChatMentionContext{ 179 ChatID: chatID, 180 MentionSuggestions: make(map[string]*MentionableUser), 181 MentionState: new(MentionState), 182 mu: new(sync.Mutex), 183 } 184 } 185 186 type mentionableUserGetter interface { 187 getMentionableUsers(chatID string) (map[string]*MentionableUser, error) 188 getMentionableUser(chatID string, pk string) (*MentionableUser, error) 189 } 190 191 type MentionManager struct { 192 mentionContexts map[string]*ChatMentionContext 193 *Messenger 194 mentionableUserGetter 195 logger *zap.Logger 196 } 197 198 func NewMentionManager(m *Messenger) *MentionManager { 199 mm := &MentionManager{ 200 mentionContexts: make(map[string]*ChatMentionContext), 201 Messenger: m, 202 logger: logutils.ZapLogger().Named("MentionManager"), 203 } 204 mm.mentionableUserGetter = mm 205 return mm 206 } 207 208 func (m *MentionManager) getChatMentionContext(chatID string) *ChatMentionContext { 209 ctx, ok := m.mentionContexts[chatID] 210 if !ok { 211 ctx = NewChatMentionContext(chatID) 212 m.mentionContexts[chatID] = ctx 213 } 214 return ctx 215 } 216 217 func (m *MentionManager) getMentionableUser(chatID string, pk string) (*MentionableUser, error) { 218 mentionableUsers, err := m.mentionableUserGetter.getMentionableUsers(chatID) 219 if err != nil { 220 return nil, err 221 } 222 user, ok := mentionableUsers[pk] 223 if !ok { 224 return nil, fmt.Errorf("user not found when getting mentionable user, pk: %s", pk) 225 } 226 return user, nil 227 } 228 229 func (m *MentionManager) getMentionableUsers(chatID string) (map[string]*MentionableUser, error) { 230 mentionableUsers := make(map[string]*MentionableUser) 231 chat, _ := m.allChats.Load(chatID) 232 if chat == nil { 233 return nil, fmt.Errorf("chat not found when getting mentionable users, chatID: %s", chatID) 234 } 235 236 var publicKeys []string 237 switch { 238 case chat.PrivateGroupChat(): 239 for _, mb := range chat.Members { 240 publicKeys = append(publicKeys, mb.ID) 241 } 242 case chat.OneToOne(): 243 publicKeys = append(publicKeys, chatID) 244 case chat.CommunityChat(): 245 community, err := m.communitiesManager.GetByIDString(chat.CommunityID) 246 if err != nil { 247 return nil, err 248 } 249 for _, pk := range community.GetMemberPubkeys() { 250 publicKeys = append(publicKeys, common.PubkeyToHex(pk)) 251 } 252 case chat.Public(): 253 m.allContacts.Range(func(pk string, _ *Contact) bool { 254 publicKeys = append(publicKeys, pk) 255 return true 256 }) 257 } 258 259 var me = m.myHexIdentity() 260 for _, pk := range publicKeys { 261 if pk == me { 262 continue 263 } 264 if err := m.addMentionableUser(mentionableUsers, pk); err != nil { 265 return nil, err 266 } 267 } 268 return mentionableUsers, nil 269 } 270 271 func (m *MentionManager) addMentionableUser(mentionableUsers map[string]*MentionableUser, publicKey string) error { 272 contact, ok := m.allContacts.Load(publicKey) 273 if !ok { 274 c, err := buildContactFromPkString(publicKey) 275 if err != nil { 276 return err 277 } 278 contact = c 279 } 280 user := &MentionableUser{ 281 Contact: contact, 282 } 283 user = addSearchablePhrases(user) 284 if user != nil { 285 mentionableUsers[publicKey] = user 286 } 287 return nil 288 } 289 290 func (m *MentionManager) ReplaceWithPublicKey(chatID, text string) (string, error) { 291 chat, _ := m.allChats.Load(chatID) 292 if chat == nil { 293 return "", fmt.Errorf("chat not found when check mentions, chatID: %s", chatID) 294 } 295 mentionableUsers, err := m.mentionableUserGetter.getMentionableUsers(chatID) 296 if err != nil { 297 return "", err 298 } 299 newText := ReplaceMentions(text, mentionableUsers) 300 m.ClearMentions(chatID) 301 return newText, nil 302 } 303 304 func withCallID(ctx *ChatMentionContext, callID uint64) *ChatMentionContext { 305 result := *ctx 306 result.CallID = callID 307 return &result 308 } 309 310 func (m *MentionManager) OnChangeText(chatID, fullText string, callID uint64) (*ChatMentionContext, error) { 311 ctx := m.getChatMentionContext(chatID) 312 if callID > 0 { 313 ctx.mu.Lock() 314 if callID <= ctx.LatestCallID { 315 ctx.mu.Unlock() 316 return withCallID(ctx, callID), fmt.Errorf("callID is less than or equal to latestCallID, callID: %d, maxCallID: %d", callID, ctx.LatestCallID) 317 } 318 ctx.LatestCallID = callID 319 ctx.mu.Unlock() 320 } 321 322 diff := diffText(ctx.PreviousText, fullText) 323 if diff == nil { 324 return withCallID(ctx, callID), nil 325 } 326 ctx.PreviousText = fullText 327 if ctx.MentionState == nil { 328 ctx.MentionState = &MentionState{} 329 } 330 ctx.MentionState.PreviousText = diff.previousText 331 ctx.MentionState.NewText = diff.newText 332 ctx.MentionState.Start = diff.start 333 ctx.MentionState.End = diff.end 334 ctx.MentionState.operation = diff.operation 335 336 atIndexes, err := calculateAtIndexEntries(ctx.MentionState) 337 if err != nil { 338 return withCallID(ctx, callID), err 339 } 340 ctx.MentionState.AtIdxs = atIndexes 341 m.logger.Debug("OnChangeText", zap.String("chatID", chatID), zap.Any("state", ctx.MentionState)) 342 ctx, err = m.calculateSuggestions(chatID, fullText) 343 return withCallID(ctx, callID), err 344 } 345 346 func (m *MentionManager) calculateSuggestions(chatID, fullText string) (*ChatMentionContext, error) { 347 ctx := m.getChatMentionContext(chatID) 348 349 mentionableUsers, err := m.mentionableUserGetter.getMentionableUsers(chatID) 350 if err != nil { 351 return nil, err 352 } 353 m.logger.Debug("calculateSuggestions", zap.String("chatID", chatID), zap.String("fullText", fullText), zap.Int("num of mentionable user", len(mentionableUsers))) 354 355 m.calculateSuggestionsWithMentionableUsers(chatID, fullText, mentionableUsers) 356 357 return ctx, nil 358 } 359 360 func (m *MentionManager) calculateSuggestionsWithMentionableUsers(chatID string, fullText string, mentionableUsers map[string]*MentionableUser) { 361 ctx := m.getChatMentionContext(chatID) 362 state := ctx.MentionState 363 364 if len(state.AtIdxs) == 0 { 365 state.AtIdxs = nil 366 ctx.MentionSuggestions = nil 367 ctx.InputSegments = []InputSegment{{ 368 Type: Text, 369 Value: fullText, 370 }} 371 return 372 } 373 374 newAtIndexEntries := checkIdxForMentions(fullText, state.AtIdxs, mentionableUsers) 375 calculatedInput, success := calculateInput(fullText, newAtIndexEntries) 376 if !success { 377 m.logger.Warn("calculateSuggestionsWithMentionableUsers: calculateInput failed", zap.String("chatID", chatID), zap.String("fullText", fullText), zap.Any("state", state)) 378 } 379 380 var end int 381 switch state.operation { 382 case textOperationAdd: 383 end = state.Start + len([]rune(state.NewText)) 384 case textOperationDelete: 385 end = state.Start 386 case textOperationReplace: 387 end = state.Start + len([]rune(state.NewText)) 388 default: 389 m.logger.Error("calculateSuggestionsWithMentionableUsers: unknown textOperation", zap.String("chatID", chatID), zap.String("fullText", fullText), zap.Any("state", state)) 390 } 391 392 atSignIdx := lastIndexOfAtSign(fullText, end) 393 var suggestions map[string]*MentionableUser 394 if atSignIdx != -1 { 395 searchedText := strings.ToLower(subs(fullText, atSignIdx+1, end)) 396 m.logger.Debug("calculateSuggestionsWithMentionableUsers", zap.Int("atSignIdx", atSignIdx), zap.String("searchedText", searchedText), zap.String("fullText", fullText), zap.Any("state", state), zap.Int("end", end)) 397 if end-atSignIdx <= 100 { 398 suggestions = getUserSuggestions(mentionableUsers, searchedText, suggestionsLimit) 399 } 400 } 401 402 state.AtSignIdx = atSignIdx 403 state.AtIdxs = newAtIndexEntries 404 state.MentionEnd = end 405 ctx.InputSegments = calculatedInput 406 ctx.MentionSuggestions = suggestions 407 } 408 409 func (m *MentionManager) SelectMention(chatID, text, primaryName, publicKey string) (*ChatMentionContext, error) { 410 ctx := m.getChatMentionContext(chatID) 411 state := ctx.MentionState 412 413 atSignIdx := state.AtSignIdx 414 mentionEnd := state.MentionEnd 415 416 var nextChar rune 417 tr := []rune(text) 418 if mentionEnd < len(tr) { 419 nextChar = tr[mentionEnd] 420 } 421 422 space := "" 423 if string(nextChar) == "" || (!unicode.IsSpace(nextChar)) { 424 space = " " 425 } 426 427 ctx.NewText = string(tr[:atSignIdx+1]) + primaryName + space + string(tr[mentionEnd:]) 428 429 _, err := m.OnChangeText(chatID, ctx.NewText, 0) 430 if err != nil { 431 return nil, err 432 } 433 m.clearSuggestions(chatID) 434 return ctx, nil 435 } 436 437 func (m *MentionManager) clearSuggestions(chatID string) { 438 m.getChatMentionContext(chatID).MentionSuggestions = nil 439 } 440 441 func (m *MentionManager) ClearMentions(chatID string) { 442 ctx := m.getChatMentionContext(chatID) 443 ctx.MentionState = nil 444 ctx.InputSegments = nil 445 ctx.NewText = "" 446 ctx.PreviousText = "" 447 m.clearSuggestions(chatID) 448 } 449 450 func (m *MentionManager) ToInputField(chatID, text string) (*ChatMentionContext, error) { 451 mentionableUsers, err := m.mentionableUserGetter.getMentionableUsers(chatID) 452 if err != nil { 453 return nil, err 454 } 455 textWithMentions := toInputField(text) 456 newText := "" 457 for i, segment := range textWithMentions { 458 if segment.Type == Mention { 459 mentionableUser := mentionableUsers[segment.Value] 460 mention := mentionableUser.GetDisplayName() 461 if !strings.HasPrefix(mention, charAtSign) { 462 segment.Value = charAtSign + mention 463 } 464 textWithMentions[i] = segment 465 } 466 newText += segment.Value 467 } 468 ctx := m.getChatMentionContext(chatID) 469 ctx.InputSegments = textWithMentions 470 ctx.MentionState = toInfo(textWithMentions) 471 ctx.NewText = newText 472 ctx.PreviousText = newText 473 return ctx, nil 474 } 475 476 func rePos(s string) []specialCharLocation { 477 var res []specialCharLocation 478 lastMatch := specialCharsRegex.FindStringIndex(s) 479 for lastMatch != nil { 480 start, end := lastMatch[0], lastMatch[1] 481 c := s[start:end] 482 res = append(res, specialCharLocation{utf8.RuneCountInString(s[:start]), c}) 483 lastMatch = specialCharsRegex.FindStringIndex(s[end:]) 484 if lastMatch != nil { 485 lastMatch[0] += end 486 lastMatch[1] += end 487 } 488 } 489 return res 490 } 491 492 func codeTagLen(idxs []specialCharLocation, idx int) int { 493 pos, c := idxs[idx].Index, idxs[idx].Value 494 495 next := func(n int) (int, string) { 496 if n < len(idxs) { 497 return idxs[n].Index, idxs[n].Value 498 } 499 return 0, "" 500 } 501 502 pos2, c2 := next(idx + 1) 503 pos3, c3 := next(idx + 2) 504 505 if c == c2 && pos == pos2-1 && c2 == c3 && pos == pos3-2 { 506 return 3 507 } 508 509 if c == c2 && pos == pos2-1 { 510 return 2 511 } 512 513 return 1 514 } 515 516 func clearPendingAtSigns(data *textMeta, from int) { 517 newIdxs := make([]int, 0) 518 for _, idx := range data.atSign.Pending { 519 if idx < from { 520 newIdxs = append(newIdxs, idx) 521 } 522 } 523 data.atSign.Pending = []int{} 524 data.atSign.Checked = append(data.atSign.Checked, newIdxs...) 525 } 526 527 func checkStyleTag(text string, idxs []specialCharLocation, idx int) (length int, canBeStart bool, canBeEnd bool) { 528 pos, c := idxs[idx].Index, idxs[idx].Value 529 tr := []rune(text) 530 next := func(n int) (int, string) { 531 if n < len(idxs) { 532 return idxs[n].Index, idxs[n].Value 533 } 534 return len(tr), "" 535 } 536 537 pos2, c2 := next(idx + 1) 538 pos3, c3 := next(idx + 2) 539 540 switch { 541 case c == c2 && c2 == c3 && pos == pos2-1 && pos == pos3-2: 542 length = 3 543 case c == c2 && pos == pos2-1: 544 length = 2 545 default: 546 length = 1 547 } 548 549 var prevC, nextC *rune 550 if decPos := pos - 1; decPos >= 0 { 551 prevC = &tr[decPos] 552 } 553 554 nextIdx := idxs[idx+length-1].Index + 1 555 if nextIdx < len(tr) { 556 nextC = &tr[nextIdx] 557 } 558 559 if length == 1 { 560 canBeEnd = prevC != nil && !unicode.IsSpace(*prevC) && (nextC == nil || unicode.IsSpace(*nextC)) 561 } else { 562 canBeEnd = prevC != nil && !unicode.IsSpace(*prevC) 563 } 564 565 canBeStart = nextC != nil && !unicode.IsSpace(*nextC) 566 return length, canBeStart, canBeEnd 567 } 568 569 func applyStyleTag(data *textMeta, idx int, pos int, c string, len int, start bool, end bool) int { 570 tag := data.styleTagMap[c] 571 tripleTilde := c == charTilde && len == 3 572 573 if tag != nil && end { 574 oldLen := (*tag).Len 575 var tagLen int 576 if tripleTilde && oldLen == 3 { 577 tagLen = 2 578 } else if oldLen >= len { 579 tagLen = len 580 } else { 581 tagLen = oldLen 582 } 583 oldIdx := (*tag).Idx 584 delete(data.styleTagMap, c) 585 clearPendingAtSigns(data, oldIdx) 586 return idx + tagLen 587 } else if start { 588 data.styleTagMap[c] = &styleTag{ 589 Len: len, 590 Idx: pos, 591 } 592 clearPendingAtSigns(data, pos) 593 } 594 return idx + len 595 } 596 597 func newTextMeta() *textMeta { 598 return &textMeta{ 599 atSign: new(atSignIndex), 600 styleTagMap: make(map[string]*styleTag), 601 } 602 } 603 604 func newDataWithAtSignAndQuoteIndex(atSign *atSignIndex, quoteIndex *int) *textMeta { 605 data := newTextMeta() 606 data.atSign = atSign 607 data.quoteIndex = quoteIndex 608 return data 609 } 610 611 func getAtSigns(text string) []int { 612 idxs := rePos(text) 613 data := newTextMeta() 614 nextIdx := 0 615 tr := []rune(text) 616 for i := range idxs { 617 if i != nextIdx { 618 continue 619 } 620 nextIdx = i + 1 621 quoteIndex := data.quoteIndex 622 c := idxs[i].Value 623 pos := idxs[i].Index 624 switch { 625 case c == charNewline: 626 prevNewline := intUnknown 627 if len(data.newlineIndexes) > 0 { 628 prevNewline = data.newlineIndexes[0] 629 } 630 631 data.newlineIndexes = append(data.newlineIndexes, pos) 632 633 if quoteIndex != nil && prevNewline != intUnknown && strings.TrimSpace(string(tr[prevNewline:pos-1])) == "" { 634 data.quoteIndex = nil 635 } 636 case quoteIndex != nil: 637 continue 638 case c == charQuote: 639 prevNewlines := make([]int, 0, 2) 640 if len(data.newlineIndexes) > 0 { 641 prevNewlines = data.newlineIndexes 642 } 643 644 if pos == 0 || 645 (len(prevNewlines) == 1 && strings.TrimSpace(string(tr[:pos-1])) == "") || 646 (len(prevNewlines) == 2 && strings.TrimSpace(string(tr[prevNewlines[0]:pos-1])) == "") { 647 data = newDataWithAtSignAndQuoteIndex(data.atSign, &pos) 648 } 649 case c == charAtSign: 650 data.atSign.Pending = append(data.atSign.Pending, pos) 651 case c == charCodeBlock: 652 length := codeTagLen(idxs, i) 653 nextIdx = applyStyleTag(data, i, pos, c, length, true, true) 654 case c == charAsterisk || c == charUnderscore || c == charTilde: 655 length, canBeStart, canBeEnd := checkStyleTag(text, idxs, i) 656 nextIdx = applyStyleTag(data, i, pos, c, length, canBeStart, canBeEnd) 657 } 658 } 659 660 return append(data.atSign.Checked, data.atSign.Pending...) 661 } 662 663 func getUserSuggestions(users map[string]*MentionableUser, searchedText string, limit int) map[string]*MentionableUser { 664 result := make(map[string]*MentionableUser) 665 for pk, user := range users { 666 match := findMatch(user, searchedText) 667 if match != "" { 668 result[pk] = &MentionableUser{ 669 searchablePhrases: user.searchablePhrases, 670 Contact: user.Contact, 671 Key: pk, 672 Match: match, 673 SearchedText: searchedText, 674 } 675 } 676 if limit != -1 && len(result) >= limit { 677 break 678 } 679 } 680 return result 681 } 682 683 // findMatch searches for a matching phrase in MentionableUser's searchable phrases or names. 684 func findMatch(user *MentionableUser, searchedText string) string { 685 if len(user.searchablePhrases) > 0 { 686 return findMatchInPhrases(user, searchedText) 687 } 688 return findMatchInNames(user, searchedText) 689 } 690 691 // findMatchInPhrases searches for a matching phrase in MentionableUser's searchable phrases. 692 func findMatchInPhrases(user *MentionableUser, searchedText string) string { 693 var match string 694 695 for _, p := range user.searchablePhrases { 696 if searchedText == "" || strings.HasPrefix(strings.ToLower(p.phrase), searchedText) { 697 match = p.originalName 698 break 699 } 700 } 701 702 return match 703 } 704 705 // findMatchInNames searches for a matching phrase in MentionableUser's primary and secondary names. 706 func findMatchInNames(user *MentionableUser, searchedText string) string { 707 var match string 708 for _, name := range user.names() { 709 if hasMatchingPrefix(name, searchedText) { 710 match = name 711 } 712 } 713 return match 714 } 715 716 // hasMatchingPrefix checks if the given text has a matching prefix with the searched text. 717 func hasMatchingPrefix(text, searchedText string) bool { 718 return text != "" && (searchedText == "" || strings.HasPrefix(strings.ToLower(text), searchedText)) 719 } 720 721 func isMentioned(user *MentionableUser, text string) bool { 722 regexStr := "" 723 for i, name := range user.names() { 724 if name == "" { 725 continue 726 } 727 name = strings.ToLower(name) 728 if i != 0 { 729 regexStr += "|" 730 } 731 regexStr += "^" + name + endingChars + "|" + "^" + name + "$" 732 } 733 regex := regexp.MustCompile(regexStr) 734 lCaseText := strings.ToLower(text) 735 return regex.MatchString(lCaseText) 736 } 737 738 func MatchMention(text string, users map[string]*MentionableUser, mentionKeyIdx int) *MentionableUser { 739 return matchMention(text, users, mentionKeyIdx, mentionKeyIdx+1, nil) 740 } 741 742 func matchMention(text string, users map[string]*MentionableUser, mentionKeyIdx int, nextWordIdx int, words []string) *MentionableUser { 743 tr := []rune(text) 744 if nextWordIdx >= len(tr) { 745 return nil 746 } 747 if word := wordRegex.FindString(string(tr[nextWordIdx:])); word != "" { 748 newWords := append(words, word) 749 750 t := strings.TrimSpace(strings.ToLower(strings.Join(newWords, ""))) 751 tt := []rune(t) 752 searchedText := t 753 if lastChar := len(tt) - 1; lastChar >= 0 && endingCharsRegex.MatchString(string(tt[lastChar:])) { 754 searchedText = string(tt[:lastChar]) 755 } 756 757 userSuggestions := getUserSuggestions(users, searchedText, suggestionsLimit) 758 userSuggestionsCnt := len(userSuggestions) 759 switch { 760 case userSuggestionsCnt == 0: 761 return nil 762 case userSuggestionsCnt == 1: 763 user := getFirstUser(userSuggestions) 764 // maybe len(users) == 1 and user input `@` so we need to recheck if the user is really mentioned 765 if isMentioned(user, string(tr[mentionKeyIdx+1:])) { 766 return user 767 } 768 case userSuggestionsCnt > 1: 769 wordLen := len([]rune(word)) 770 textLen := len(tr) 771 nextWordStart := nextWordIdx + wordLen 772 if textLen > nextWordStart { 773 user := matchMention(text, users, mentionKeyIdx, nextWordStart, newWords) 774 if user != nil { 775 return user 776 } 777 } 778 return filterWithFullMatch(userSuggestions, searchedText) 779 } 780 } 781 return nil 782 } 783 784 func filterWithFullMatch(userSuggestions map[string]*MentionableUser, text string) *MentionableUser { 785 if text == "" { 786 return nil 787 } 788 result := make(map[string]*MentionableUser) 789 for pk, user := range userSuggestions { 790 for _, name := range user.names() { 791 if strings.ToLower(name) == text { 792 result[pk] = user 793 } 794 } 795 } 796 return getFirstUser(result) 797 } 798 799 func getFirstUser(userSuggestions map[string]*MentionableUser) *MentionableUser { 800 for _, user := range userSuggestions { 801 return user 802 } 803 return nil 804 } 805 806 func ReplaceMentions(text string, users map[string]*MentionableUser) string { 807 idxs := getAtSigns(text) 808 return replaceMentions(text, users, idxs, 0) 809 } 810 811 func replaceMentions(text string, users map[string]*MentionableUser, idxs []int, diff int) string { 812 if strings.TrimSpace(text) == "" || len(idxs) == 0 { 813 return text 814 } 815 816 mentionKeyIdx := idxs[0] - diff 817 818 if len(users) == 0 { 819 return text 820 } 821 822 matchUser := MatchMention(text, users, mentionKeyIdx) 823 if matchUser == nil { 824 return replaceMentions(text, users, idxs[1:], diff) 825 } 826 827 tr := []rune(text) 828 newText := string(tr[:mentionKeyIdx+1]) + matchUser.ID + string(tr[mentionKeyIdx+1+len([]rune(matchUser.Match)):]) 829 newDiff := diff + len(tr) - len([]rune(newText)) 830 831 return replaceMentions(newText, users, idxs[1:], newDiff) 832 } 833 834 func addSearchablePhrases(user *MentionableUser) *MentionableUser { 835 if !user.Blocked { 836 searchablePhrases := user.names() 837 for _, s := range searchablePhrases { 838 if s != "" { 839 newWords := []string{s} 840 newWords = append(newWords, strings.Split(s, " ")[1:]...) 841 var phrases []searchablePhrase 842 for _, w := range newWords { 843 phrases = append(phrases, searchablePhrase{s, w}) 844 } 845 user.searchablePhrases = append(user.searchablePhrases, phrases...) 846 } 847 } 848 return user 849 } 850 return nil 851 } 852 853 type AtIndexEntry struct { 854 From int 855 To int 856 Checked bool 857 858 Mentioned bool 859 NextAtIdx int 860 } 861 862 func (e *AtIndexEntry) String() string { 863 return fmt.Sprintf("{From: %d, To: %d, Checked: %t, Mentioned: %t, NextAtIdx: %d}", e.From, e.To, e.Checked, e.Mentioned, e.NextAtIdx) 864 } 865 866 func calculateAtIndexEntries(state *MentionState) ([]*AtIndexEntry, error) { 867 var keptAtIndexEntries []*AtIndexEntry 868 var oldRunes []rune 869 var newRunes []rune 870 var previousRunes = []rune(state.PreviousText) 871 switch state.operation { 872 case textOperationAdd: 873 newRunes = []rune(state.NewText) 874 case textOperationDelete: 875 oldRunes = previousRunes[state.Start : state.End+1] 876 case textOperationReplace: 877 oldRunes = previousRunes[state.Start : state.End+1] 878 newRunes = []rune(state.NewText) 879 default: 880 return nil, fmt.Errorf("unknown text operation: %d", state.operation) 881 } 882 883 oldLen := len(oldRunes) 884 newLen := len(newRunes) 885 diff := newLen - oldLen 886 oldAtSignIndexes := getAtSignIdxs(string(oldRunes), state.Start) 887 newAtSignIndexes := getAtSignIdxs(state.NewText, state.Start) 888 for _, entry := range state.AtIdxs { 889 deleted := false 890 for _, idx := range oldAtSignIndexes { 891 if idx == entry.From { 892 deleted = true 893 } 894 } 895 if !deleted { 896 if entry.From >= state.Start { // update range with diff 897 entry.From += diff 898 entry.To += diff 899 } 900 if entry.From < state.Start && entry.To+1 >= state.Start { // impacted after user edit so need to be rechecked 901 entry.Checked = false 902 } 903 keptAtIndexEntries = append(keptAtIndexEntries, entry) 904 } 905 } 906 return addNewAtSignIndexes(keptAtIndexEntries, newAtSignIndexes), nil 907 } 908 909 func addNewAtSignIndexes(keptAtIdxs []*AtIndexEntry, newAtSignIndexes []int) []*AtIndexEntry { 910 var newAtIndexEntries []*AtIndexEntry 911 var added bool 912 var lastNewIdx int 913 newAtSignIndexesCount := len(newAtSignIndexes) 914 if newAtSignIndexesCount > 0 { 915 lastNewIdx = newAtSignIndexes[newAtSignIndexesCount-1] 916 } 917 for _, entry := range keptAtIdxs { 918 if newAtSignIndexesCount > 0 && !added && entry.From > lastNewIdx { 919 newAtIndexEntries = append(newAtIndexEntries, makeAtIdxs(newAtSignIndexes)...) 920 newAtIndexEntries = append(newAtIndexEntries, entry) 921 added = true 922 } else { 923 newAtIndexEntries = append(newAtIndexEntries, entry) 924 } 925 } 926 if !added { 927 newAtIndexEntries = append(newAtIndexEntries, makeAtIdxs(newAtSignIndexes)...) 928 } 929 return newAtIndexEntries 930 } 931 932 func makeAtIdxs(idxs []int) []*AtIndexEntry { 933 result := make([]*AtIndexEntry, len(idxs)) 934 for i, idx := range idxs { 935 result[i] = &AtIndexEntry{ 936 From: idx, 937 Checked: false, 938 } 939 } 940 return result 941 } 942 943 // getAtSignIdxs returns the indexes of all @ signs in the text. 944 // delta is the offset of the text within the original text. 945 func getAtSignIdxs(text string, delta int) []int { 946 return getAtSignIdxsHelper(text, delta, 0, []int{}) 947 } 948 949 func getAtSignIdxsHelper(text string, delta int, from int, idxs []int) []int { 950 tr := []rune(text) 951 idx := strings.IndexRune(string(tr[from:]), '@') 952 if idx != -1 { 953 idx = utf8.RuneCountInString(text[:idx]) 954 idx += from 955 idxs = append(idxs, delta+idx) 956 return getAtSignIdxsHelper(text, delta, idx+1, idxs) 957 } 958 return idxs 959 } 960 961 func checkAtIndexEntry(fullText string, entry *AtIndexEntry, mentionableUsers map[string]*MentionableUser) *AtIndexEntry { 962 if entry.Checked { 963 return entry 964 } 965 result := MatchMention(fullText, mentionableUsers, entry.From) 966 if result != nil && result.Match != "" { 967 return &AtIndexEntry{ 968 From: entry.From, 969 To: entry.From + len([]rune(result.Match)), 970 Checked: true, 971 Mentioned: true, 972 } 973 } 974 return &AtIndexEntry{ 975 From: entry.From, 976 To: len([]rune(fullText)), 977 Checked: true, 978 } 979 } 980 981 func checkIdxForMentions(fullText string, currentAtIndexEntries []*AtIndexEntry, mentionableUsers map[string]*MentionableUser) []*AtIndexEntry { 982 var newIndexEntries []*AtIndexEntry 983 for _, entry := range currentAtIndexEntries { 984 previousEntryIdx := len(newIndexEntries) - 1 985 newEntry := checkAtIndexEntry(fullText, entry, mentionableUsers) 986 if previousEntryIdx >= 0 && !newIndexEntries[previousEntryIdx].Mentioned { 987 newIndexEntries[previousEntryIdx].To = entry.From - 1 988 } 989 if previousEntryIdx >= 0 { 990 newIndexEntries[previousEntryIdx].NextAtIdx = entry.From 991 } 992 newEntry.NextAtIdx = intUnknown 993 newIndexEntries = append(newIndexEntries, newEntry) 994 } 995 996 if len(newIndexEntries) > 0 { 997 lastIdx := len(newIndexEntries) - 1 998 if newIndexEntries[lastIdx].Mentioned { 999 return newIndexEntries 1000 } 1001 newIndexEntries[lastIdx].To = len([]rune(fullText)) - 1 1002 newIndexEntries[lastIdx].Checked = false 1003 return newIndexEntries 1004 } 1005 1006 return nil 1007 } 1008 1009 func appendInputSegment(result *[]InputSegment, typ SegmentType, value string, fullText *string) { 1010 if value != "" { 1011 *result = append(*result, InputSegment{Type: typ, Value: value}) 1012 *fullText += value 1013 } 1014 } 1015 1016 func calculateInput(text string, atIndexEntries []*AtIndexEntry) ([]InputSegment, bool) { 1017 if len(atIndexEntries) == 0 { 1018 return []InputSegment{{Type: Text, Value: text}}, true 1019 } 1020 idxCount := len(atIndexEntries) 1021 lastFrom := atIndexEntries[idxCount-1].From 1022 1023 var result []InputSegment 1024 fullText := "" 1025 if atIndexEntries[0].From != 0 { 1026 t := subs(text, 0, atIndexEntries[0].From) 1027 appendInputSegment(&result, Text, t, &fullText) 1028 } 1029 1030 for _, entry := range atIndexEntries { 1031 from := entry.From 1032 to := entry.To 1033 nextAtIdx := entry.NextAtIdx 1034 mentioned := entry.Mentioned 1035 1036 if mentioned && nextAtIdx != intUnknown { 1037 t := subs(text, from, to+1) 1038 appendInputSegment(&result, Mention, t, &fullText) 1039 1040 t = subs(text, to+1, nextAtIdx) 1041 appendInputSegment(&result, Text, t, &fullText) 1042 } else if mentioned && lastFrom == from { 1043 t := subs(text, from, to+1) 1044 appendInputSegment(&result, Mention, t, &fullText) 1045 1046 t = subs(text, to+1) 1047 appendInputSegment(&result, Text, t, &fullText) 1048 } else { 1049 t := subs(text, from, to+1) 1050 appendInputSegment(&result, Text, t, &fullText) 1051 } 1052 } 1053 1054 return result, fullText == text 1055 } 1056 1057 func subs(s string, start int, end ...int) string { 1058 tr := []rune(s) 1059 e := len(tr) 1060 1061 if len(end) > 0 { 1062 e = end[0] 1063 } 1064 1065 if start < 0 { 1066 start = 0 1067 } 1068 1069 if e > len(tr) { 1070 e = len(tr) 1071 } 1072 1073 if e < 0 { 1074 e = 0 1075 } 1076 1077 if start > e { 1078 start, e = e, start 1079 if e > len(tr) { 1080 e = len(tr) 1081 } 1082 } 1083 1084 return string(tr[start:e]) 1085 } 1086 1087 func isValidTerminatingCharacter(c rune) bool { 1088 switch c { 1089 case '\t': // tab 1090 return true 1091 case '\n': // newline 1092 return true 1093 case '\f': // new page 1094 return true 1095 case '\r': // carriage return 1096 return true 1097 case ' ': // whitespace 1098 return true 1099 case ',': 1100 return true 1101 case '.': 1102 return true 1103 case ':': 1104 return true 1105 case ';': 1106 return true 1107 default: 1108 return false 1109 } 1110 } 1111 1112 var hexReg = regexp.MustCompile("[0-9a-f]") 1113 1114 func isPublicKeyCharacter(c rune) bool { 1115 return hexReg.MatchString(string(c)) 1116 } 1117 1118 const mentionLength = 133 1119 1120 func toInputField(text string) []InputSegment { 1121 // Initialize the variables 1122 currentMentionLength := 0 1123 currentText := "" 1124 currentMention := "" 1125 var inputFieldEntries []InputSegment 1126 1127 // Iterate through each character in the input text 1128 for _, character := range text { 1129 isPKCharacter := isPublicKeyCharacter(character) 1130 isTerminationCharacter := isValidTerminatingCharacter(character) 1131 1132 switch { 1133 // It's a valid mention. 1134 // Add any text that is before if present 1135 // and add the mention. 1136 // Set the text to the new termination character 1137 case currentMentionLength == mentionLength && isTerminationCharacter: 1138 if currentText != "" { 1139 inputFieldEntries = append(inputFieldEntries, InputSegment{Type: Text, Value: currentText}) 1140 } 1141 inputFieldEntries = append(inputFieldEntries, InputSegment{Type: Mention, Value: currentMention}) 1142 currentMentionLength = 0 1143 currentMention = "" 1144 currentText = string(character) 1145 1146 // It's either a pk character, or the `x` in the pk 1147 // in this case add the text to the mention and continue 1148 case (isPKCharacter && currentMentionLength > 0) || (currentMentionLength == 2 && character == 'x'): 1149 currentMentionLength++ 1150 currentMention += string(character) 1151 1152 // The beginning of a mention, discard the @ sign 1153 // and start following a mention 1154 case character == '@': 1155 currentMentionLength = 1 1156 currentMention = "" 1157 1158 // Not a mention character, but we were following a mention 1159 // discard everything up to now and count as text 1160 case !isPKCharacter && currentMentionLength > 0: 1161 currentText += "@" + currentMention + string(character) 1162 currentMentionLength = 0 1163 currentMention = "" 1164 1165 // Just a normal text character 1166 default: 1167 currentText += string(character) 1168 } 1169 } 1170 1171 // Process any remaining mention/text 1172 if currentText != "" { 1173 inputFieldEntries = append(inputFieldEntries, InputSegment{Type: Text, Value: currentText}) 1174 } 1175 if currentMentionLength == mentionLength { 1176 inputFieldEntries = append(inputFieldEntries, InputSegment{Type: Mention, Value: currentMention}) 1177 } 1178 1179 return inputFieldEntries 1180 } 1181 1182 func toInfo(inputSegments []InputSegment) *MentionState { 1183 newText := "" 1184 state := &MentionState{ 1185 AtSignIdx: intUnknown, 1186 End: intUnknown, 1187 AtIdxs: []*AtIndexEntry{}, 1188 MentionEnd: 0, 1189 PreviousText: "", 1190 NewText: newText, 1191 Start: intUnknown, 1192 } 1193 1194 for _, segment := range inputSegments { 1195 t := segment.Type 1196 text := segment.Value 1197 tr := []rune(text) 1198 1199 if t == Mention { 1200 newMention := &AtIndexEntry{ 1201 Checked: true, 1202 Mentioned: true, 1203 From: state.MentionEnd, 1204 To: state.Start + len(tr), 1205 } 1206 1207 if len(state.AtIdxs) > 0 { 1208 lastIdx := state.AtIdxs[len(state.AtIdxs)-1] 1209 state.AtIdxs = state.AtIdxs[:len(state.AtIdxs)-1] 1210 lastIdx.NextAtIdx = state.MentionEnd 1211 state.AtIdxs = append(state.AtIdxs, lastIdx) 1212 } 1213 state.AtIdxs = append(state.AtIdxs, newMention) 1214 state.AtSignIdx = state.MentionEnd 1215 } 1216 1217 state.MentionEnd += len(tr) 1218 state.NewText = string(tr[len(tr)-1]) 1219 state.Start += len(tr) 1220 state.End += len(tr) 1221 } 1222 1223 return state 1224 } 1225 1226 // lastIndexOfAtSign returns the index of the last occurrence of substr in s starting from index start. 1227 // If substr is not present in s, it returns -1. 1228 func lastIndexOfAtSign(s string, start int) int { 1229 if start < 0 { 1230 return -1 1231 } 1232 1233 t := []rune(s) 1234 if start >= len(t) { 1235 start = len(t) - 1 1236 } 1237 1238 // Reverse the input strings to find the first occurrence of the reversed substr in the reversed s. 1239 reversedS := reverse(t[:start+1]) 1240 1241 idx := strings.IndexRune(reversedS, '@') 1242 1243 if idx == -1 { 1244 return -1 1245 } 1246 1247 // Calculate the index in the original string. 1248 idx = utf8.RuneCountInString(reversedS[:idx]) 1249 return start - idx 1250 } 1251 1252 // reverse returns the reversed string of input s. 1253 func reverse(r []rune) string { 1254 for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { 1255 r[i], r[j] = r[j], r[i] 1256 } 1257 return string(r) 1258 } 1259 1260 type textOperation int 1261 1262 const ( 1263 textOperationAdd textOperation = iota + 1 1264 textOperationDelete 1265 textOperationReplace 1266 ) 1267 1268 type TextDiff struct { 1269 previousText string 1270 newText string // if add operation, newText is the added text; if replace operation, newText is the text used to replace the previousText 1271 start int // start index of the operation relate to previousText 1272 end int // end index of the operation relate to previousText, always the same as start if the operation is add, range: start<=end<=len(previousText)-1 1273 operation textOperation 1274 } 1275 1276 func diffText(oldText, newText string) *TextDiff { 1277 if oldText == newText { 1278 return nil 1279 } 1280 t1 := []rune(oldText) 1281 t2 := []rune(newText) 1282 oldLen := len(t1) 1283 newLen := len(t2) 1284 if oldLen == 0 { 1285 return &TextDiff{previousText: oldText, newText: newText, start: 0, end: 0, operation: textOperationAdd} 1286 } 1287 if newLen == 0 { 1288 return &TextDiff{previousText: oldText, newText: "", start: 0, end: oldLen, operation: textOperationReplace} 1289 } 1290 1291 // if we reach here, t1 and t2 are not empty 1292 start := 0 1293 for start < oldLen && start < newLen && t1[start] == t2[start] { 1294 start++ 1295 } 1296 1297 oldEnd, newEnd := oldLen, newLen 1298 for oldEnd > start && newEnd > start && t1[oldEnd-1] == t2[newEnd-1] { 1299 oldEnd-- 1300 newEnd-- 1301 } 1302 1303 diff := &TextDiff{previousText: oldText, start: start} 1304 if newLen > oldLen && (start == oldLen || oldEnd == 0 || start == oldEnd) { 1305 diff.operation = textOperationAdd 1306 diff.end = start 1307 diff.newText = string(t2[start:newEnd]) 1308 } else if newLen < oldLen && (start == newLen || newEnd == 0 || start == newEnd) { 1309 diff.operation = textOperationDelete 1310 diff.end = oldEnd - 1 1311 } else { 1312 diff.operation = textOperationReplace 1313 if start == 0 && oldEnd == oldLen { // full replace 1314 diff.end = oldLen - 1 1315 diff.newText = newText 1316 } else { // partial replace 1317 diff.end = oldEnd - 1 1318 diff.newText = string(t2[start:newEnd]) 1319 } 1320 } 1321 return diff 1322 }