status-go/protocol/messenger_mention.go

1298 lines
34 KiB
Go

package protocol
// this is a reimplementation of the mention feature in status-react
// reference implementation: https://github.com/status-im/status-react/blob/972347963498fc4a2bb8f85541e79ed0541698da/src/status_im/chat/models/mentions.cljs#L1
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"unicode"
"unicode/utf8"
"go.uber.org/zap"
"github.com/status-im/status-go/logutils"
"github.com/status-im/status-go/api/multiformat"
"github.com/status-im/status-go/protocol/common"
)
const (
endingChars = `[\s\.,;:]`
charAtSign = "@"
charQuote = ">"
charNewline = "\n"
charAsterisk = "*"
charUnderscore = "_"
charTilde = "~"
charCodeBlock = "`"
intUnknown = -1
)
var (
specialCharsRegex = regexp.MustCompile("[@~\\\\*_\n>`]{1}")
endingCharsRegex = regexp.MustCompile(endingChars)
wordRegex = regexp.MustCompile("^[\\w\\d\\-]*" + endingChars + "|[\\S]+")
)
type specialCharLocation struct {
Index int
Value string
}
type atSignIndex struct {
Pending []int
Checked []int
}
type styleTag struct {
Len int
Idx int
}
type textMeta struct {
atSign *atSignIndex
styleTagMap map[string]*styleTag
quoteIndex *int
newlineIndexes []int
}
type searchablePhrase struct {
originalName string
phrase string
}
type MentionableUser struct {
*Contact
searchablePhrases []searchablePhrase
Key string // a unique identifier of a mentionable user
Match string
SearchedText string
}
func (c *MentionableUser) MarshalJSON() ([]byte, error) {
compressedKey, err := multiformat.SerializeLegacyKey(c.ID)
if err != nil {
return nil, err
}
type MentionableUserJSON struct {
PrimaryName string `json:"primaryName"`
SecondaryName string `json:"secondaryName"`
ENSVerified bool `json:"ensVerified"`
Added bool `json:"added"`
DisplayName string `json:"displayName"`
Key string `json:"key"`
Match string `json:"match"`
SearchedText string `json:"searchedText"`
ID string `json:"id"`
CompressedKey string `json:"compressedKey,omitempty"`
}
contactJSON := MentionableUserJSON{
PrimaryName: c.PrimaryName(),
SecondaryName: c.SecondaryName(),
ENSVerified: c.ENSVerified,
Added: c.added(),
DisplayName: c.GetDisplayName(),
Key: c.Key,
Match: c.Match,
SearchedText: c.SearchedText,
ID: c.ID,
CompressedKey: compressedKey,
}
return json.Marshal(contactJSON)
}
func (c *MentionableUser) GetDisplayName() string {
if c.ENSVerified && c.EnsName != "" {
return c.EnsName
}
if c.DisplayName != "" {
return c.DisplayName
}
if c.PrimaryName() != "" {
return c.PrimaryName()
}
return c.Alias
}
type SegmentType int
const (
Text SegmentType = iota
Mention
)
type InputSegment struct {
Type SegmentType `json:"type"`
Value string `json:"value"`
}
type MentionState struct {
AtSignIdx int
AtIdxs []*AtIndexEntry
MentionEnd int
PreviousText string
NewText string
Start int
End int
operation textOperation
}
func (ms *MentionState) String() string {
atIdxsStr := ""
for i, entry := range ms.AtIdxs {
if i > 0 {
atIdxsStr += ", "
}
atIdxsStr += fmt.Sprintf("%+v", entry)
}
return fmt.Sprintf("MentionState{AtSignIdx: %d, AtIdxs: [%s], MentionEnd: %d, PreviousText: %q, NewText: %s, Start: %d, End: %d}",
ms.AtSignIdx, atIdxsStr, ms.MentionEnd, ms.PreviousText, ms.NewText, ms.Start, ms.End)
}
type ChatMentionContext struct {
ChatID string
InputSegments []InputSegment
MentionSuggestions map[string]*MentionableUser
MentionState *MentionState
PreviousText string // user input text before the last change
NewText string
}
func NewChatMentionContext(chatID string) *ChatMentionContext {
return &ChatMentionContext{
ChatID: chatID,
MentionSuggestions: make(map[string]*MentionableUser),
MentionState: new(MentionState),
}
}
type mentionableUserGetter interface {
getMentionableUsers(chatID string) (map[string]*MentionableUser, error)
getMentionableUser(chatID string, pk string) (*MentionableUser, error)
}
type MentionManager struct {
mentionContexts map[string]*ChatMentionContext
*Messenger
mentionableUserGetter
logger *zap.Logger
}
func NewMentionManager(m *Messenger) *MentionManager {
mm := &MentionManager{
mentionContexts: make(map[string]*ChatMentionContext),
Messenger: m,
logger: logutils.ZapLogger().Named("MentionManager"),
}
mm.mentionableUserGetter = mm
return mm
}
func (m *MentionManager) getChatMentionContext(chatID string) *ChatMentionContext {
ctx, ok := m.mentionContexts[chatID]
if !ok {
ctx = NewChatMentionContext(chatID)
m.mentionContexts[chatID] = ctx
}
return ctx
}
func (m *MentionManager) getMentionableUser(chatID string, pk string) (*MentionableUser, error) {
mentionableUsers, err := m.mentionableUserGetter.getMentionableUsers(chatID)
if err != nil {
return nil, err
}
user, ok := mentionableUsers[pk]
if !ok {
return nil, fmt.Errorf("user not found when getting mentionable user, pk: %s", pk)
}
return user, nil
}
func (m *MentionManager) getMentionableUsers(chatID string) (map[string]*MentionableUser, error) {
mentionableUsers := make(map[string]*MentionableUser)
chat, _ := m.allChats.Load(chatID)
if chat == nil {
return nil, fmt.Errorf("chat not found when getting mentionable users, chatID: %s", chatID)
}
var publicKeys []string
switch {
case chat.PrivateGroupChat():
for _, mb := range chat.Members {
publicKeys = append(publicKeys, mb.ID)
}
case chat.OneToOne():
publicKeys = append(publicKeys, chatID)
case chat.CommunityChat():
community, err := m.communitiesManager.GetByIDString(chat.CommunityID)
if err != nil {
return nil, err
}
for _, pk := range community.GetMemberPubkeys() {
publicKeys = append(publicKeys, common.PubkeyToHex(pk))
}
case chat.Public():
m.allContacts.Range(func(pk string, _ *Contact) bool {
publicKeys = append(publicKeys, pk)
return true
})
}
var me = m.myHexIdentity()
for _, pk := range publicKeys {
if pk == me {
continue
}
if err := m.addMentionableUser(mentionableUsers, pk); err != nil {
return nil, err
}
}
return mentionableUsers, nil
}
func (m *MentionManager) addMentionableUser(mentionableUsers map[string]*MentionableUser, publicKey string) error {
contact, ok := m.allContacts.Load(publicKey)
if !ok {
c, err := buildContactFromPkString(publicKey)
if err != nil {
return err
}
contact = c
}
user := &MentionableUser{
Contact: contact,
}
user = addSearchablePhrases(user)
if user != nil {
mentionableUsers[publicKey] = user
}
return nil
}
func (m *MentionManager) ReplaceWithPublicKey(chatID, text string) (string, error) {
chat, _ := m.allChats.Load(chatID)
if chat == nil {
return "", fmt.Errorf("chat not found when check mentions, chatID: %s", chatID)
}
mentionableUsers, err := m.mentionableUserGetter.getMentionableUsers(chatID)
if err != nil {
return "", err
}
newText := ReplaceMentions(text, mentionableUsers)
m.ClearMentions(chatID)
return newText, nil
}
func (m *MentionManager) OnChangeText(chatID, fullText string) (*ChatMentionContext, error) {
ctx := m.getChatMentionContext(chatID)
diff := diffText(ctx.PreviousText, fullText)
if diff == nil {
return ctx, nil
}
ctx.PreviousText = fullText
if ctx.MentionState == nil {
ctx.MentionState = &MentionState{}
}
ctx.MentionState.PreviousText = diff.previousText
ctx.MentionState.NewText = diff.newText
ctx.MentionState.Start = diff.start
ctx.MentionState.End = diff.end
ctx.MentionState.operation = diff.operation
atIndexes, err := calculateAtIndexEntries(ctx.MentionState)
if err != nil {
return ctx, err
}
ctx.MentionState.AtIdxs = atIndexes
m.logger.Debug("OnChangeText", zap.String("chatID", chatID), zap.Any("state", ctx.MentionState))
return m.calculateSuggestions(chatID, fullText)
}
func (m *MentionManager) calculateSuggestions(chatID, fullText string) (*ChatMentionContext, error) {
ctx := m.getChatMentionContext(chatID)
mentionableUsers, err := m.mentionableUserGetter.getMentionableUsers(chatID)
if err != nil {
return nil, err
}
m.logger.Debug("calculateSuggestions", zap.String("chatID", chatID), zap.String("fullText", fullText), zap.Int("num of mentionable user", len(mentionableUsers)))
m.calculateSuggestionsWithMentionableUsers(chatID, fullText, mentionableUsers)
return ctx, nil
}
func (m *MentionManager) calculateSuggestionsWithMentionableUsers(chatID string, fullText string, mentionableUsers map[string]*MentionableUser) {
ctx := m.getChatMentionContext(chatID)
state := ctx.MentionState
if len(state.AtIdxs) == 0 {
state.AtIdxs = nil
ctx.MentionSuggestions = nil
ctx.InputSegments = []InputSegment{{
Type: Text,
Value: fullText,
}}
return
}
newAtIndexEntries := checkIdxForMentions(fullText, state.AtIdxs, mentionableUsers)
calculatedInput, success := calculateInput(fullText, newAtIndexEntries)
if !success {
m.logger.Warn("calculateSuggestionsWithMentionableUsers: calculateInput failed", zap.String("chatID", chatID), zap.String("fullText", fullText), zap.Any("state", state))
}
var end int
switch state.operation {
case textOperationAdd:
end = state.Start + len([]rune(state.NewText))
case textOperationDelete:
end = state.Start
case textOperationReplace:
end = state.Start + len([]rune(state.NewText))
default:
m.logger.Error("calculateSuggestionsWithMentionableUsers: unknown textOperation", zap.String("chatID", chatID), zap.String("fullText", fullText), zap.Any("state", state))
}
atSignIdx := lastIndexOfAtSign(fullText, end)
var suggestions map[string]*MentionableUser
if atSignIdx != -1 {
searchedText := strings.ToLower(subs(fullText, atSignIdx+1, end))
m.logger.Debug("calculateSuggestionsWithMentionableUsers", zap.Int("atSignIdx", atSignIdx), zap.String("searchedText", searchedText), zap.String("fullText", fullText), zap.Any("state", state), zap.Int("end", end))
if end-atSignIdx <= 100 {
suggestions = getUserSuggestions(mentionableUsers, searchedText, -1)
}
}
state.AtSignIdx = atSignIdx
state.AtIdxs = newAtIndexEntries
state.MentionEnd = end
ctx.InputSegments = calculatedInput
ctx.MentionSuggestions = suggestions
}
func (m *MentionManager) SelectMention(chatID, text, primaryName, publicKey string) (*ChatMentionContext, error) {
ctx := m.getChatMentionContext(chatID)
state := ctx.MentionState
atSignIdx := state.AtSignIdx
mentionEnd := state.MentionEnd
var nextChar rune
tr := []rune(text)
if mentionEnd < len(tr) {
nextChar = tr[mentionEnd]
}
space := ""
if string(nextChar) == "" || (!unicode.IsSpace(nextChar)) {
space = " "
}
ctx.NewText = string(tr[:atSignIdx+1]) + primaryName + space + string(tr[mentionEnd:])
ctx, err := m.OnChangeText(chatID, ctx.NewText)
if err != nil {
return nil, err
}
m.clearSuggestions(chatID)
return ctx, nil
}
func (m *MentionManager) clearSuggestions(chatID string) {
m.getChatMentionContext(chatID).MentionSuggestions = nil
}
func (m *MentionManager) ClearMentions(chatID string) {
ctx := m.getChatMentionContext(chatID)
ctx.MentionState = nil
ctx.InputSegments = nil
ctx.NewText = ""
ctx.PreviousText = ""
m.clearSuggestions(chatID)
}
func (m *MentionManager) ToInputField(chatID, text string) (*ChatMentionContext, error) {
mentionableUsers, err := m.mentionableUserGetter.getMentionableUsers(chatID)
if err != nil {
return nil, err
}
textWithMentions := toInputField(text)
newText := ""
for i, segment := range textWithMentions {
if segment.Type == Mention {
mentionableUser := mentionableUsers[segment.Value]
mention := mentionableUser.GetDisplayName()
if !strings.HasPrefix(mention, charAtSign) {
segment.Value = charAtSign + mention
}
textWithMentions[i] = segment
}
newText += segment.Value
}
ctx := m.getChatMentionContext(chatID)
ctx.InputSegments = textWithMentions
ctx.MentionState = toInfo(textWithMentions)
ctx.NewText = newText
ctx.PreviousText = newText
return ctx, nil
}
func rePos(s string) []specialCharLocation {
var res []specialCharLocation
lastMatch := specialCharsRegex.FindStringIndex(s)
for lastMatch != nil {
start, end := lastMatch[0], lastMatch[1]
c := s[start:end]
res = append(res, specialCharLocation{utf8.RuneCountInString(s[:start]), c})
lastMatch = specialCharsRegex.FindStringIndex(s[end:])
if lastMatch != nil {
lastMatch[0] += end
lastMatch[1] += end
}
}
return res
}
func codeTagLen(idxs []specialCharLocation, idx int) int {
pos, c := idxs[idx].Index, idxs[idx].Value
next := func(n int) (int, string) {
if n < len(idxs) {
return idxs[n].Index, idxs[n].Value
}
return 0, ""
}
pos2, c2 := next(idx + 1)
pos3, c3 := next(idx + 2)
if c == c2 && pos == pos2-1 && c2 == c3 && pos == pos3-2 {
return 3
}
if c == c2 && pos == pos2-1 {
return 2
}
return 1
}
func clearPendingAtSigns(data *textMeta, from int) {
newIdxs := make([]int, 0)
for _, idx := range data.atSign.Pending {
if idx < from {
newIdxs = append(newIdxs, idx)
}
}
data.atSign.Pending = []int{}
data.atSign.Checked = append(data.atSign.Checked, newIdxs...)
}
func checkStyleTag(text string, idxs []specialCharLocation, idx int) (length int, canBeStart bool, canBeEnd bool) {
pos, c := idxs[idx].Index, idxs[idx].Value
tr := []rune(text)
next := func(n int) (int, string) {
if n < len(idxs) {
return idxs[n].Index, idxs[n].Value
}
return len(tr), ""
}
pos2, c2 := next(idx + 1)
pos3, c3 := next(idx + 2)
switch {
case c == c2 && c2 == c3 && pos == pos2-1 && pos == pos3-2:
length = 3
case c == c2 && pos == pos2-1:
length = 2
default:
length = 1
}
var prevC, nextC *rune
if decPos := pos - 1; decPos >= 0 {
prevC = &tr[decPos]
}
nextIdx := idxs[idx+length-1].Index + 1
if nextIdx < len(tr) {
nextC = &tr[nextIdx]
}
if length == 1 {
canBeEnd = prevC != nil && !unicode.IsSpace(*prevC) && (nextC == nil || unicode.IsSpace(*nextC))
} else {
canBeEnd = prevC != nil && !unicode.IsSpace(*prevC)
}
canBeStart = nextC != nil && !unicode.IsSpace(*nextC)
return length, canBeStart, canBeEnd
}
func applyStyleTag(data *textMeta, idx int, pos int, c string, len int, start bool, end bool) int {
tag := data.styleTagMap[c]
tripleTilde := c == charTilde && len == 3
if tag != nil && end {
oldLen := (*tag).Len
var tagLen int
if tripleTilde && oldLen == 3 {
tagLen = 2
} else if oldLen >= len {
tagLen = len
} else {
tagLen = oldLen
}
oldIdx := (*tag).Idx
delete(data.styleTagMap, c)
clearPendingAtSigns(data, oldIdx)
return idx + tagLen
} else if start {
data.styleTagMap[c] = &styleTag{
Len: len,
Idx: pos,
}
clearPendingAtSigns(data, pos)
}
return idx + len
}
func newTextMeta() *textMeta {
return &textMeta{
atSign: new(atSignIndex),
styleTagMap: make(map[string]*styleTag),
}
}
func newDataWithAtSignAndQuoteIndex(atSign *atSignIndex, quoteIndex *int) *textMeta {
data := newTextMeta()
data.atSign = atSign
data.quoteIndex = quoteIndex
return data
}
func getAtSigns(text string) []int {
idxs := rePos(text)
data := newTextMeta()
nextIdx := 0
tr := []rune(text)
for i := range idxs {
if i != nextIdx {
continue
}
nextIdx = i + 1
quoteIndex := data.quoteIndex
c := idxs[i].Value
pos := idxs[i].Index
switch {
case c == charNewline:
prevNewline := intUnknown
if len(data.newlineIndexes) > 0 {
prevNewline = data.newlineIndexes[0]
}
data.newlineIndexes = append(data.newlineIndexes, pos)
if quoteIndex != nil && prevNewline != intUnknown && strings.TrimSpace(string(tr[prevNewline:pos-1])) == "" {
data.quoteIndex = nil
}
case quoteIndex != nil:
continue
case c == charQuote:
prevNewlines := make([]int, 0, 2)
if len(data.newlineIndexes) > 0 {
prevNewlines = data.newlineIndexes
}
if pos == 0 ||
(len(prevNewlines) == 1 && strings.TrimSpace(string(tr[:pos-1])) == "") ||
(len(prevNewlines) == 2 && strings.TrimSpace(string(tr[prevNewlines[0]:pos-1])) == "") {
data = newDataWithAtSignAndQuoteIndex(data.atSign, &pos)
}
case c == charAtSign:
data.atSign.Pending = append(data.atSign.Pending, pos)
case c == charCodeBlock:
length := codeTagLen(idxs, i)
nextIdx = applyStyleTag(data, i, pos, c, length, true, true)
case c == charAsterisk || c == charUnderscore || c == charTilde:
length, canBeStart, canBeEnd := checkStyleTag(text, idxs, i)
nextIdx = applyStyleTag(data, i, pos, c, length, canBeStart, canBeEnd)
}
}
return append(data.atSign.Checked, data.atSign.Pending...)
}
func getUserSuggestions(users map[string]*MentionableUser, searchedText string, limit int) map[string]*MentionableUser {
result := make(map[string]*MentionableUser)
for pk, user := range users {
match := findMatch(user, searchedText)
if match != "" {
result[pk] = &MentionableUser{
searchablePhrases: user.searchablePhrases,
Contact: user.Contact,
Key: pk,
Match: match,
SearchedText: searchedText,
}
}
if limit != -1 && len(result) >= limit {
break
}
}
return result
}
// findMatch searches for a matching phrase in MentionableUser's searchable phrases or names.
func findMatch(user *MentionableUser, searchedText string) string {
if len(user.searchablePhrases) > 0 {
return findMatchInPhrases(user, searchedText)
}
return findMatchInNames(user, searchedText)
}
// findMatchInPhrases searches for a matching phrase in MentionableUser's searchable phrases.
func findMatchInPhrases(user *MentionableUser, searchedText string) string {
var match string
for _, p := range user.searchablePhrases {
if searchedText == "" || strings.HasPrefix(strings.ToLower(p.phrase), searchedText) {
match = p.originalName
break
}
}
return match
}
// findMatchInNames searches for a matching phrase in MentionableUser's primary and secondary names.
func findMatchInNames(user *MentionableUser, searchedText string) string {
var match string
for _, name := range user.names() {
if hasMatchingPrefix(name, searchedText) {
match = name
}
}
return match
}
// hasMatchingPrefix checks if the given text has a matching prefix with the searched text.
func hasMatchingPrefix(text, searchedText string) bool {
return text != "" && (searchedText == "" || strings.HasPrefix(strings.ToLower(text), searchedText))
}
func isMentioned(user *MentionableUser, text string) bool {
regexStr := ""
for i, name := range user.names() {
if name == "" {
continue
}
name = strings.ToLower(name)
if i != 0 {
regexStr += "|"
}
regexStr += "^" + name + endingChars + "|" + "^" + name + "$"
}
regex := regexp.MustCompile(regexStr)
lCaseText := strings.ToLower(text)
return regex.MatchString(lCaseText)
}
func MatchMention(text string, users map[string]*MentionableUser, mentionKeyIdx int) *MentionableUser {
return matchMention(text, users, mentionKeyIdx, mentionKeyIdx+1, nil)
}
func matchMention(text string, users map[string]*MentionableUser, mentionKeyIdx int, nextWordIdx int, words []string) *MentionableUser {
tr := []rune(text)
if nextWordIdx >= len(tr) {
return nil
}
if word := wordRegex.FindString(string(tr[nextWordIdx:])); word != "" {
newWords := append(words, word)
t := strings.TrimSpace(strings.ToLower(strings.Join(newWords, "")))
tt := []rune(t)
searchedText := t
if lastChar := len(tt) - 1; lastChar >= 0 && endingCharsRegex.MatchString(string(tt[lastChar:])) {
searchedText = string(tt[:lastChar])
}
userSuggestions := getUserSuggestions(users, searchedText, -1)
userSuggestionsCnt := len(userSuggestions)
switch {
case userSuggestionsCnt == 0:
return nil
case userSuggestionsCnt == 1:
user := getFirstUser(userSuggestions)
// maybe len(users) == 1 and user input `@` so we need to recheck if the user is really mentioned
if isMentioned(user, string(tr[mentionKeyIdx+1:])) {
return user
}
case userSuggestionsCnt > 1:
wordLen := len([]rune(word))
textLen := len(tr)
nextWordStart := nextWordIdx + wordLen
if textLen > nextWordStart {
user := matchMention(text, users, mentionKeyIdx, nextWordStart, newWords)
if user != nil {
return user
}
}
return filterWithFullMatch(userSuggestions, searchedText)
}
}
return nil
}
func filterWithFullMatch(userSuggestions map[string]*MentionableUser, text string) *MentionableUser {
if text == "" {
return nil
}
result := make(map[string]*MentionableUser)
for pk, user := range userSuggestions {
for _, name := range user.names() {
if strings.ToLower(name) == text {
result[pk] = user
}
}
}
return getFirstUser(result)
}
func getFirstUser(userSuggestions map[string]*MentionableUser) *MentionableUser {
for _, user := range userSuggestions {
return user
}
return nil
}
func ReplaceMentions(text string, users map[string]*MentionableUser) string {
idxs := getAtSigns(text)
return replaceMentions(text, users, idxs, 0)
}
func replaceMentions(text string, users map[string]*MentionableUser, idxs []int, diff int) string {
if strings.TrimSpace(text) == "" || len(idxs) == 0 {
return text
}
mentionKeyIdx := idxs[0] - diff
if len(users) == 0 {
return text
}
matchUser := MatchMention(text, users, mentionKeyIdx)
if matchUser == nil {
return replaceMentions(text, users, idxs[1:], diff)
}
tr := []rune(text)
newText := string(tr[:mentionKeyIdx+1]) + matchUser.ID + string(tr[mentionKeyIdx+1+len([]rune(matchUser.Match)):])
newDiff := diff + len(tr) - len([]rune(newText))
return replaceMentions(newText, users, idxs[1:], newDiff)
}
func addSearchablePhrases(user *MentionableUser) *MentionableUser {
if !user.Blocked {
searchablePhrases := user.names()
for _, s := range searchablePhrases {
if s != "" {
newWords := []string{s}
newWords = append(newWords, strings.Split(s, " ")[1:]...)
var phrases []searchablePhrase
for _, w := range newWords {
phrases = append(phrases, searchablePhrase{s, w})
}
user.searchablePhrases = append(user.searchablePhrases, phrases...)
}
}
return user
}
return nil
}
type AtIndexEntry struct {
From int
To int
Checked bool
Mentioned bool
NextAtIdx int
}
func (e *AtIndexEntry) String() string {
return fmt.Sprintf("{From: %d, To: %d, Checked: %t, Mentioned: %t, NextAtIdx: %d}", e.From, e.To, e.Checked, e.Mentioned, e.NextAtIdx)
}
func calculateAtIndexEntries(state *MentionState) ([]*AtIndexEntry, error) {
var keptAtIndexEntries []*AtIndexEntry
var oldRunes []rune
var newRunes []rune
var previousRunes = []rune(state.PreviousText)
switch state.operation {
case textOperationAdd:
newRunes = []rune(state.NewText)
case textOperationDelete:
oldRunes = previousRunes[state.Start : state.End+1]
case textOperationReplace:
oldRunes = previousRunes[state.Start : state.End+1]
newRunes = []rune(state.NewText)
default:
return nil, fmt.Errorf("unknown text operation: %d", state.operation)
}
oldLen := len(oldRunes)
newLen := len(newRunes)
diff := newLen - oldLen
oldAtSignIndexes := getAtSignIdxs(string(oldRunes), state.Start)
newAtSignIndexes := getAtSignIdxs(state.NewText, state.Start)
for _, entry := range state.AtIdxs {
deleted := false
for _, idx := range oldAtSignIndexes {
if idx == entry.From {
deleted = true
}
}
if !deleted {
if entry.From >= state.Start { // update range with diff
entry.From += diff
entry.To += diff
}
if entry.From < state.Start && entry.To+1 >= state.Start { // impacted after user edit so need to be rechecked
entry.Checked = false
}
keptAtIndexEntries = append(keptAtIndexEntries, entry)
}
}
return addNewAtSignIndexes(keptAtIndexEntries, newAtSignIndexes), nil
}
func addNewAtSignIndexes(keptAtIdxs []*AtIndexEntry, newAtSignIndexes []int) []*AtIndexEntry {
var newAtIndexEntries []*AtIndexEntry
var added bool
var lastNewIdx int
newAtSignIndexesCount := len(newAtSignIndexes)
if newAtSignIndexesCount > 0 {
lastNewIdx = newAtSignIndexes[newAtSignIndexesCount-1]
}
for _, entry := range keptAtIdxs {
if newAtSignIndexesCount > 0 && !added && entry.From > lastNewIdx {
newAtIndexEntries = append(newAtIndexEntries, makeAtIdxs(newAtSignIndexes)...)
newAtIndexEntries = append(newAtIndexEntries, entry)
added = true
} else {
newAtIndexEntries = append(newAtIndexEntries, entry)
}
}
if !added {
newAtIndexEntries = append(newAtIndexEntries, makeAtIdxs(newAtSignIndexes)...)
}
return newAtIndexEntries
}
func makeAtIdxs(idxs []int) []*AtIndexEntry {
result := make([]*AtIndexEntry, len(idxs))
for i, idx := range idxs {
result[i] = &AtIndexEntry{
From: idx,
Checked: false,
}
}
return result
}
// getAtSignIdxs returns the indexes of all @ signs in the text.
// delta is the offset of the text within the original text.
func getAtSignIdxs(text string, delta int) []int {
return getAtSignIdxsHelper(text, delta, 0, []int{})
}
func getAtSignIdxsHelper(text string, delta int, from int, idxs []int) []int {
tr := []rune(text)
idx := strings.IndexRune(string(tr[from:]), '@')
if idx != -1 {
idx = utf8.RuneCountInString(text[:idx])
idx += from
idxs = append(idxs, delta+idx)
return getAtSignIdxsHelper(text, delta, idx+1, idxs)
}
return idxs
}
func checkAtIndexEntry(fullText string, entry *AtIndexEntry, mentionableUsers map[string]*MentionableUser) *AtIndexEntry {
if entry.Checked {
return entry
}
result := MatchMention(fullText, mentionableUsers, entry.From)
if result != nil && result.Match != "" {
return &AtIndexEntry{
From: entry.From,
To: entry.From + len([]rune(result.Match)),
Checked: true,
Mentioned: true,
}
}
return &AtIndexEntry{
From: entry.From,
To: len([]rune(fullText)),
Checked: true,
}
}
func checkIdxForMentions(fullText string, currentAtIndexEntries []*AtIndexEntry, mentionableUsers map[string]*MentionableUser) []*AtIndexEntry {
var newIndexEntries []*AtIndexEntry
for _, entry := range currentAtIndexEntries {
previousEntryIdx := len(newIndexEntries) - 1
newEntry := checkAtIndexEntry(fullText, entry, mentionableUsers)
if previousEntryIdx >= 0 && !newIndexEntries[previousEntryIdx].Mentioned {
newIndexEntries[previousEntryIdx].To = entry.From - 1
}
if previousEntryIdx >= 0 {
newIndexEntries[previousEntryIdx].NextAtIdx = entry.From
}
newEntry.NextAtIdx = intUnknown
newIndexEntries = append(newIndexEntries, newEntry)
}
if len(newIndexEntries) > 0 {
lastIdx := len(newIndexEntries) - 1
if newIndexEntries[lastIdx].Mentioned {
return newIndexEntries
}
newIndexEntries[lastIdx].To = len([]rune(fullText)) - 1
newIndexEntries[lastIdx].Checked = false
return newIndexEntries
}
return nil
}
func appendInputSegment(result *[]InputSegment, typ SegmentType, value string, fullText *string) {
if value != "" {
*result = append(*result, InputSegment{Type: typ, Value: value})
*fullText += value
}
}
func calculateInput(text string, atIndexEntries []*AtIndexEntry) ([]InputSegment, bool) {
if len(atIndexEntries) == 0 {
return []InputSegment{{Type: Text, Value: text}}, true
}
idxCount := len(atIndexEntries)
lastFrom := atIndexEntries[idxCount-1].From
var result []InputSegment
fullText := ""
if atIndexEntries[0].From != 0 {
t := subs(text, 0, atIndexEntries[0].From)
appendInputSegment(&result, Text, t, &fullText)
}
for _, entry := range atIndexEntries {
from := entry.From
to := entry.To
nextAtIdx := entry.NextAtIdx
mentioned := entry.Mentioned
if mentioned && nextAtIdx != intUnknown {
t := subs(text, from, to+1)
appendInputSegment(&result, Mention, t, &fullText)
t = subs(text, to+1, nextAtIdx)
appendInputSegment(&result, Text, t, &fullText)
} else if mentioned && lastFrom == from {
t := subs(text, from, to+1)
appendInputSegment(&result, Mention, t, &fullText)
t = subs(text, to+1)
appendInputSegment(&result, Text, t, &fullText)
} else {
t := subs(text, from, to+1)
appendInputSegment(&result, Text, t, &fullText)
}
}
return result, fullText == text
}
func subs(s string, start int, end ...int) string {
tr := []rune(s)
e := len(tr)
if len(end) > 0 {
e = end[0]
}
if start < 0 {
start = 0
}
if e > len(tr) {
e = len(tr)
}
if e < 0 {
e = 0
}
if start > e {
start, e = e, start
if e > len(tr) {
e = len(tr)
}
}
return string(tr[start:e])
}
func isValidTerminatingCharacter(c rune) bool {
switch c {
case '\t': // tab
return true
case '\n': // newline
return true
case '\f': // new page
return true
case '\r': // carriage return
return true
case ' ': // whitespace
return true
case ',':
return true
case '.':
return true
case ':':
return true
case ';':
return true
default:
return false
}
}
var hexReg = regexp.MustCompile("[0-9a-f]")
func isPublicKeyCharacter(c rune) bool {
return hexReg.MatchString(string(c))
}
const mentionLength = 133
func toInputField(text string) []InputSegment {
// Initialize the variables
currentMentionLength := 0
currentText := ""
currentMention := ""
var inputFieldEntries []InputSegment
// Iterate through each character in the input text
for _, character := range text {
isPKCharacter := isPublicKeyCharacter(character)
isTerminationCharacter := isValidTerminatingCharacter(character)
switch {
// It's a valid mention.
// Add any text that is before if present
// and add the mention.
// Set the text to the new termination character
case currentMentionLength == mentionLength && isTerminationCharacter:
if currentText != "" {
inputFieldEntries = append(inputFieldEntries, InputSegment{Type: Text, Value: currentText})
}
inputFieldEntries = append(inputFieldEntries, InputSegment{Type: Mention, Value: currentMention})
currentMentionLength = 0
currentMention = ""
currentText = string(character)
// It's either a pk character, or the `x` in the pk
// in this case add the text to the mention and continue
case (isPKCharacter && currentMentionLength > 0) || (currentMentionLength == 2 && character == 'x'):
currentMentionLength++
currentMention += string(character)
// The beginning of a mention, discard the @ sign
// and start following a mention
case character == '@':
currentMentionLength = 1
currentMention = ""
// Not a mention character, but we were following a mention
// discard everything up to now and count as text
case !isPKCharacter && currentMentionLength > 0:
currentText += "@" + currentMention + string(character)
currentMentionLength = 0
currentMention = ""
// Just a normal text character
default:
currentText += string(character)
}
}
// Process any remaining mention/text
if currentText != "" {
inputFieldEntries = append(inputFieldEntries, InputSegment{Type: Text, Value: currentText})
}
if currentMentionLength == mentionLength {
inputFieldEntries = append(inputFieldEntries, InputSegment{Type: Mention, Value: currentMention})
}
return inputFieldEntries
}
func toInfo(inputSegments []InputSegment) *MentionState {
newText := ""
state := &MentionState{
AtSignIdx: intUnknown,
End: intUnknown,
AtIdxs: []*AtIndexEntry{},
MentionEnd: 0,
PreviousText: "",
NewText: newText,
Start: intUnknown,
}
for _, segment := range inputSegments {
t := segment.Type
text := segment.Value
tr := []rune(text)
if t == Mention {
newMention := &AtIndexEntry{
Checked: true,
Mentioned: true,
From: state.MentionEnd,
To: state.Start + len(tr),
}
if len(state.AtIdxs) > 0 {
lastIdx := state.AtIdxs[len(state.AtIdxs)-1]
state.AtIdxs = state.AtIdxs[:len(state.AtIdxs)-1]
lastIdx.NextAtIdx = state.MentionEnd
state.AtIdxs = append(state.AtIdxs, lastIdx)
}
state.AtIdxs = append(state.AtIdxs, newMention)
state.AtSignIdx = state.MentionEnd
}
state.MentionEnd += len(tr)
state.NewText = string(tr[len(tr)-1])
state.Start += len(tr)
state.End += len(tr)
}
return state
}
// lastIndexOfAtSign returns the index of the last occurrence of substr in s starting from index start.
// If substr is not present in s, it returns -1.
func lastIndexOfAtSign(s string, start int) int {
if start < 0 {
return -1
}
t := []rune(s)
if start >= len(t) {
start = len(t) - 1
}
// Reverse the input strings to find the first occurrence of the reversed substr in the reversed s.
reversedS := reverse(t[:start+1])
idx := strings.IndexRune(reversedS, '@')
if idx == -1 {
return -1
}
// Calculate the index in the original string.
idx = utf8.RuneCountInString(reversedS[:idx])
return start - idx
}
// reverse returns the reversed string of input s.
func reverse(r []rune) string {
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
type textOperation int
const (
textOperationAdd textOperation = iota + 1
textOperationDelete
textOperationReplace
)
type TextDiff struct {
previousText string
newText string // if add operation, newText is the added text; if replace operation, newText is the text used to replace the previousText
start int // start index of the operation relate to previousText
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
operation textOperation
}
func diffText(oldText, newText string) *TextDiff {
if oldText == newText {
return nil
}
t1 := []rune(oldText)
t2 := []rune(newText)
oldLen := len(t1)
newLen := len(t2)
if oldLen == 0 {
return &TextDiff{previousText: oldText, newText: newText, start: 0, end: 0, operation: textOperationAdd}
}
if newLen == 0 {
return &TextDiff{previousText: oldText, newText: "", start: 0, end: oldLen, operation: textOperationReplace}
}
// if we reach here, t1 and t2 are not empty
start := 0
for start < oldLen && start < newLen && t1[start] == t2[start] {
start++
}
oldEnd, newEnd := oldLen, newLen
for oldEnd > start && newEnd > start && t1[oldEnd-1] == t2[newEnd-1] {
oldEnd--
newEnd--
}
diff := &TextDiff{previousText: oldText, start: start}
if newLen > oldLen && (start == oldLen || oldEnd == 0 || start == oldEnd) {
diff.operation = textOperationAdd
diff.end = start
diff.newText = string(t2[start:newEnd])
} else if newLen < oldLen && (start == newLen || newEnd == 0 || start == newEnd) {
diff.operation = textOperationDelete
diff.end = oldEnd - 1
} else {
diff.operation = textOperationReplace
if start == 0 && oldEnd == oldLen { // full replace
diff.end = oldLen - 1
diff.newText = newText
} else { // partial replace
diff.end = oldEnd - 1
diff.newText = string(t2[start:newEnd])
}
}
return diff
}