status-go/protocol/messenger_mention.go

1292 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 MentionableUser struct {
*Contact
primaryName string
secondaryName string
searchablePhrases []string
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) PrimaryName() string {
if c.primaryName != "" {
return c.primaryName
}
return c.Contact.PrimaryName()
}
func (c *MentionableUser) SecondaryName() string {
if c.secondaryName != "" {
return c.secondaryName
}
return c.Contact.SecondaryName()
}
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 // searched text
NewText *string // the matched username
Start int // position after the @
End int // position of the end of newText
}
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.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) CheckMentions(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.getMentionableUsers(chatID)
if err != nil {
return "", err
}
newText := ReplaceMentions(text, mentionableUsers)
m.ClearMentions(chatID)
return newText, nil
}
func (m *MentionManager) OnChangeText(chatID, text string) (*ChatMentionContext, error) {
ctx := m.getChatMentionContext(chatID)
diff := diffText(ctx.PreviousText, text)
if diff == nil {
return ctx, nil
}
ctx.PreviousText = text
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.AtIdxs = calcAtIdxs(ctx.MentionState)
m.logger.Debug("OnChangeText", zap.String("chatID", chatID), zap.Any("state", ctx.MentionState))
return m.CalculateSuggestions(chatID, text)
}
func (m *MentionManager) RecheckAtIdxs(chatID string, text string, publicKey string) (*ChatMentionContext, error) {
user, err := m.getMentionableUser(chatID, publicKey)
if err != nil {
return nil, err
}
ctx := m.getChatMentionContext(chatID)
state := ctx.MentionState
mentionableUsers := map[string]*MentionableUser{user.ID: user}
newAtIdxs := checkIdxForMentions(text, state.AtIdxs, mentionableUsers)
ctx.InputSegments = calculateInput(text, newAtIdxs)
state.AtIdxs = newAtIdxs
return ctx, nil
}
func (m *MentionManager) CalculateSuggestions(chatID, text 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("text", text), zap.Int("num of mentionable user", len(mentionableUsers)))
m.calculateSuggestions(chatID, text, mentionableUsers)
return ctx, nil
}
func (m *MentionManager) calculateSuggestions(chatID string, text string, mentionableUsers map[string]*MentionableUser) {
ctx := m.getChatMentionContext(chatID)
state := ctx.MentionState
newText := state.NewText
if newText == nil {
newText = &text
}
if len(state.AtIdxs) == 0 {
state.AtIdxs = nil
ctx.MentionSuggestions = nil
ctx.InputSegments = []InputSegment{{
Type: Text,
Value: text,
}}
return
}
newAtIdxs := checkIdxForMentions(text, state.AtIdxs, mentionableUsers)
calculatedInput := calculateInput(text, newAtIdxs)
addition := state.Start <= state.End
var end int
if addition {
end = state.Start + len([]rune(*newText))
} else {
end = state.Start
}
atSignIdx := lastIndexOf(text, charAtSign, state.End)
searchedText := strings.ToLower(subs(text, atSignIdx+1, end))
m.logger.Debug("calculateSuggestions", zap.Int("atSignIdx", atSignIdx), zap.String("searchedText", searchedText), zap.String("text", text), zap.Any("state", state))
var suggestions map[string]*MentionableUser
if (atSignIdx <= state.Start && end-atSignIdx <= 100) || text[len(text)-1] == charAtSign[0] {
suggestions = getUserSuggestions(mentionableUsers, searchedText, -1)
}
state.AtSignIdx = atSignIdx
state.AtIdxs = newAtIdxs
state.MentionEnd = end
ctx.InputSegments = calculatedInput
ctx.MentionSuggestions = suggestions
}
func (m *MentionManager) NewInputTextWithMention(chatID, text, primaryName string) *ChatMentionContext {
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:])
return ctx
}
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) HandleSelectionChange(chatID, text string, start int, end int) *ChatMentionContext {
ctx := m.getChatMentionContext(chatID)
m.handleSelectionChange(chatID, text, start, end, ctx.MentionSuggestions)
return ctx
}
func (m *MentionManager) handleSelectionChange(chatID, text string, start int, end int, mentionableUsers map[string]*MentionableUser) {
ctx := m.getChatMentionContext(chatID)
state := ctx.MentionState
if state != nil && len(state.AtIdxs) > 0 {
var atIdx *AtIndexEntry
for _, idx := range state.AtIdxs {
if start >= idx.From && end-1 <= idx.To {
atIdx = idx
break
}
}
if atIdx != nil {
newText := ""
state.Start = end
state.End = end
state.NewText = &newText
m.calculateSuggestions(chatID, text, mentionableUsers)
} else {
m.clearSuggestions(chatID)
}
}
ctx.PreviousText = text
}
func (m *MentionManager) ToInputField(chatID, text string) (*ChatMentionContext, error) {
mentionableUsers, err := m.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
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{
primaryName: user.PrimaryName(),
secondaryName: user.SecondaryName(),
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 _, phrase := range user.searchablePhrases {
if searchedText == "" || strings.HasPrefix(strings.ToLower(phrase), searchedText) {
match = primaryOrSecondaryName(user)
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
if hasMatchingPrefix(user.PrimaryName(), searchedText) {
match = primaryOrSecondaryName(user)
} else if hasMatchingPrefix(user.SecondaryName(), searchedText) {
match = user.SecondaryName()
}
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))
}
// primaryOrSecondaryName returns the primary name if it is not empty, otherwise returns the secondary name.
func primaryOrSecondaryName(user *MentionableUser) string {
if primaryName := user.PrimaryName(); primaryName != "" {
return primaryName
}
return user.SecondaryName()
}
func isMentioned(user *MentionableUser, text string) bool {
lCasePName := strings.ToLower(user.PrimaryName())
lCaseSName := strings.ToLower(user.SecondaryName())
regexStr := "^" + lCasePName + endingChars + "|" + "^" + lCasePName + "$" + "|" + "^" + lCaseSName + endingChars + "|" + "^" + lCaseSName + "$"
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 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)
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 {
return matchMention(text, users, mentionKeyIdx, nextWordStart, newWords)
}
}
}
return nil
}
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 := []string{user.PrimaryName(), user.SecondaryName()}
for _, s := range searchablePhrases {
if s != "" {
newWords := []string{s}
newWords = append(newWords, strings.Split(s, " ")[1:]...)
user.searchablePhrases = append(user.searchablePhrases, newWords...)
}
}
return user
}
return nil
}
type AtIndexEntry struct {
From int
To int
Checked bool
Mentioned bool
Mention bool
NextAtIdx int
}
// implementation reference: https://github.com/status-im/status-react/blob/04d0252e013d9c67862e77a3467dd32c3abde934/src/status_im/chat/models/mentions.cljs#L433
func calcAtIdxs(state *MentionState) []*AtIndexEntry {
newIdxs := getAtSignIdxs(*state.NewText, state.Start)
newIdxCnt := len(newIdxs)
var lastNewIdx *int
if newIdxCnt > 0 {
idx := newIdxs[newIdxCnt-1]
lastNewIdx = &idx
}
newTextLen := len([]rune(*state.NewText))
oldTextLen := len([]rune(state.PreviousText))
oldEnd := state.Start + oldTextLen
if len(state.AtIdxs) == 0 {
result := make([]*AtIndexEntry, newIdxCnt)
for i, idx := range newIdxs {
result[i] = &AtIndexEntry{
From: idx,
Checked: false,
}
}
return result
}
diff := newTextLen - oldTextLen
var keptAtIdxs []*AtIndexEntry
for _, entry := range state.AtIdxs {
from := entry.From
to := entry.To
toPlus1 := to + 1
if from >= oldEnd {
entry.From = from + diff
entry.To = to + diff
keptAtIdxs = append(keptAtIdxs, entry)
} else if from < state.Start && toPlus1 < state.Start {
// NOTE: (not to+1) means is not checked yet, but (not to+1) seems always false, so we ignore it here temporarily
// https://github.com/status-im/status-mobile/blob/04d0252e013d9c67862e77a3467dd32c3abde934/src/status_im/chat/models/mentions.cljs#L454
keptAtIdxs = append(keptAtIdxs, entry)
} else if from < state.Start && toPlus1 >= state.Start {
keptAtIdxs = append(keptAtIdxs, &AtIndexEntry{
From: from,
Checked: false,
})
}
}
var newState []*AtIndexEntry
var added bool
for _, entry := range keptAtIdxs {
if lastNewIdx != nil && entry.From > *lastNewIdx && !added {
newState = append(newState, makeAtIdxs(newIdxs)...)
newState = append(newState, entry)
added = true
} else {
newState = append(newState, entry)
}
}
if !added {
newState = append(newState, makeAtIdxs(newIdxs)...)
}
return newState
}
func makeAtIdxs(idxs []int) []*AtIndexEntry {
result := make([]*AtIndexEntry, len(idxs))
for i, idx := range idxs {
result[i] = &AtIndexEntry{
From: idx,
Checked: false,
}
}
return result
}
func getAtSignIdxs(text string, start int) []int {
return getAtSignIdxsHelper(text, start, 0, []int{})
}
func getAtSignIdxsHelper(text string, start int, from int, idxs []int) []int {
tr := []rune(text)
idx := strings.Index(string(tr[from:]), charAtSign)
if idx != -1 {
idx += from
idxs = append(idxs, start+idx)
return getAtSignIdxsHelper(text, start, idx+1, idxs)
}
return idxs
}
func checkEntry(text string, entry *AtIndexEntry, mentionableUsers map[string]*MentionableUser) *AtIndexEntry {
if entry.Checked {
return entry
}
result := MatchMention(text+charAtSign, 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(text)),
Checked: true,
Mention: false, // Mention vs Mentioned? wrong spelling?
}
}
func checkIdxForMentions(text string, idxs []*AtIndexEntry, mentionableUsers map[string]*MentionableUser) []*AtIndexEntry {
var newIdxs []*AtIndexEntry
for _, entry := range idxs {
previousEntryIdx := len(newIdxs) - 1
newEntry := checkEntry(text, entry, mentionableUsers)
if previousEntryIdx >= 0 && !newIdxs[previousEntryIdx].Mentioned {
newIdxs[previousEntryIdx].To = entry.From - 1
}
if previousEntryIdx >= 0 {
newIdxs[previousEntryIdx].NextAtIdx = entry.From
}
// simulate (dissoc new-entry :next-at-idx)
newEntry.NextAtIdx = intUnknown
newIdxs = append(newIdxs, newEntry)
}
if len(newIdxs) > 0 {
lastIdx := len(newIdxs) - 1
if newIdxs[lastIdx].Mentioned {
return newIdxs
}
newIdxs[lastIdx].To = len([]rune(text)) - 1
newIdxs[lastIdx].Checked = false
return newIdxs
}
return nil
}
func calculateInput(text string, idxs []*AtIndexEntry) []InputSegment {
if len(idxs) == 0 {
return []InputSegment{{Type: Text, Value: text}}
}
idxCount := len(idxs)
lastFrom := idxs[idxCount-1].From
var result []InputSegment
if idxs[0].From != 0 {
result = append(result, InputSegment{Type: Text, Value: subs(text, 0, idxs[0].From)})
}
for _, entry := range idxs {
from := entry.From
to := entry.To
nextAtIdx := entry.NextAtIdx
mentioned := entry.Mentioned
if mentioned && nextAtIdx != intUnknown {
result = append(result, InputSegment{Type: Mention, Value: subs(text, from, to+1)})
result = append(result, InputSegment{Type: Text, Value: subs(text, to+1, nextAtIdx)})
} else if mentioned && lastFrom == from {
result = append(result, InputSegment{Type: Mention, Value: subs(text, from, to+1)})
result = append(result, InputSegment{Type: Text, Value: subs(text, to+1)})
} else {
result = append(result, InputSegment{Type: Text, Value: subs(text, from, to+1)})
}
}
return result
}
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)
nt := string(tr[len(tr)-1])
state.NewText = &nt
state.Start += len(tr)
state.End += len(tr)
}
return state
}
// lastIndexOf 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 lastIndexOf(s, substr string, start int) int {
if start < 0 {
return -1
}
t := []rune(s)
tt := []rune(substr)
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])
reversedSubstr := reverse(tt)
idx := strings.Index(reversedS, reversedSubstr)
if idx == -1 {
return -1
}
// Calculate the index in the original string.
return start - idx - len(tt) + 1
}
// 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 TextDiff struct {
previousText string
newText string // we always set it to empty if it's a delete operation
start int
end int
}
// hasCommonCharSequence checks if str1 has a common character sequence with str2.
// It iterates through both strings and compares their characters one by one.
// The function returns true if all characters in str1 can be found in str2 in the same order, but not necessarily consecutively.
// This is helpful for determining if there is an insertion or deletion operation between two strings.
func hasCommonCharSequence(str1, str2 []rune) bool {
i, j := 0, 0
for i < len(str1) && j < len(str2) {
if str1[i] == str2[j] {
i++
}
j++
}
return i == len(str1)
}
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}
}
if newLen == 0 {
return &TextDiff{previousText: oldText, newText: "", start: 0, end: oldLen}
}
// 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 hasCommonCharSequence(t1, t2) { // is just a insert operation
diff.end = start
diff.newText = string(t2[start:newEnd])
} else {
diff.end = newEnd
if oldLen > newLen {
diff.end = oldEnd
}
if !hasCommonCharSequence(t2, t1) { // is not a delete operation
diff.newText = string(t2[start:diff.end])
}
}
return diff
}