fix mobile mention issue #15616 (#3388)

* fix mobile mention issue #15616

* add state != nil

* clear previous text when clear mentions

* fix: after selected mention user, and type @ not working

* bump version
This commit is contained in:
frank 2023-04-27 11:06:40 +08:00 committed by GitHub
parent 83ad76637a
commit 8608aecdb4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 477 additions and 76 deletions

View File

@ -1 +1 @@
0.148.2
0.148.3

View File

@ -10,6 +10,10 @@ import (
"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"
)
@ -160,7 +164,7 @@ func (ms *MentionState) String() string {
}
atIdxsStr += fmt.Sprintf("%+v", entry)
}
return fmt.Sprintf("MentionState{AtSignIdx: %d, AtIdxs: [%s], MentionEnd: %d, PreviousText: %q, NewText: %q, Start: %d, End: %d}",
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)
}
@ -169,6 +173,7 @@ type ChatMentionContext struct {
InputSegments []InputSegment
MentionSuggestions map[string]*MentionableUser
MentionState *MentionState
PreviousText string // user input text before the last change
NewText string
}
@ -189,12 +194,14 @@ 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
@ -296,25 +303,24 @@ func (m *MentionManager) CheckMentions(chatID, text string) (string, error) {
return newText, nil
}
func (m *MentionManager) OnTextInput(chatID string, state *MentionState) (*ChatMentionContext, error) {
if state == nil {
return nil, fmt.Errorf("mention[OnTextInput] state should not be nil")
}
func (m *MentionManager) OnChangeText(chatID, text string) (*ChatMentionContext, error) {
ctx := m.getChatMentionContext(chatID)
previousState := ctx.MentionState
var newState *MentionState
if previousState == nil {
newState = state
} else {
previousState.PreviousText = state.PreviousText
previousState.NewText = state.NewText
previousState.Start = state.Start
previousState.End = state.End
newState = previousState
diff := diffText(ctx.PreviousText, text)
if diff == nil {
return ctx, nil
}
newState.AtIdxs = calcAtIdxs(newState)
ctx.MentionState = newState
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) {
@ -338,6 +344,7 @@ func (m *MentionManager) CalculateSuggestions(chatID, text string) (*ChatMention
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)
@ -371,12 +378,12 @@ func (m *MentionManager) calculateSuggestions(chatID string, text string, mentio
} else {
end = state.Start
}
atSignIdx := LastIndexOf(text, charAtSign, state.Start)
tr := []rune(text)
searchedText := strings.ToLower(string(tr[atSignIdx+1 : end]))
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 {
if (atSignIdx <= state.Start && end-atSignIdx <= 100) || text[len(text)-1] == charAtSign[0] {
suggestions = getUserSuggestions(mentionableUsers, searchedText, -1)
}
@ -418,6 +425,7 @@ func (m *MentionManager) ClearMentions(chatID string) {
ctx.MentionState = nil
ctx.InputSegments = nil
ctx.NewText = ""
ctx.PreviousText = ""
m.clearSuggestions(chatID)
}
@ -430,7 +438,7 @@ func (m *MentionManager) HandleSelectionChange(chatID, text string, start int, e
func (m *MentionManager) handleSelectionChange(chatID, text string, start int, end int, mentionableUsers map[string]*MentionableUser) {
ctx := m.getChatMentionContext(chatID)
state := ctx.MentionState
if len(state.AtIdxs) > 0 {
if state != nil && len(state.AtIdxs) > 0 {
var atIdx *AtIndexEntry
for _, idx := range state.AtIdxs {
if start >= idx.From && end-1 <= idx.To {
@ -449,6 +457,7 @@ func (m *MentionManager) handleSelectionChange(chatID, text string, start int, e
m.clearSuggestions(chatID)
}
}
ctx.PreviousText = text
}
func (m *MentionManager) ToInputField(chatID, text string) (*ChatMentionContext, error) {
@ -982,14 +991,13 @@ func calculateInput(text string, idxs []*AtIndexEntry) []InputSegment {
if len(idxs) == 0 {
return []InputSegment{{Type: Text, Value: text}}
}
tr := []rune(text)
idxCount := len(idxs)
lastFrom := idxs[idxCount-1].From
var result []InputSegment
if idxs[0].From != 0 {
result = append(result, InputSegment{Type: Text, Value: string(tr[:idxs[0].From])})
result = append(result, InputSegment{Type: Text, Value: subs(text, 0, idxs[0].From)})
}
for _, entry := range idxs {
@ -999,19 +1007,49 @@ func calculateInput(text string, idxs []*AtIndexEntry) []InputSegment {
mentioned := entry.Mentioned
if mentioned && nextAtIdx != intUnknown {
result = append(result, InputSegment{Type: Mention, Value: string(tr[from : to+1])})
result = append(result, InputSegment{Type: Text, Value: string(tr[to+1 : nextAtIdx])})
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: string(tr[from : to+1])})
result = append(result, InputSegment{Type: Text, Value: string(tr[to+1:])})
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: string(tr[from : to+1])})
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
@ -1152,9 +1190,9 @@ func toInfo(inputSegments []InputSegment) *MentionState {
return state
}
// LastIndexOf returns the index of the last occurrence of substr in s starting from index start.
// 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 {
func lastIndexOf(s, substr string, start int) int {
if start < 0 {
return -1
}
@ -1186,3 +1224,68 @@ func reverse(r []rune) string {
}
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
}

View File

@ -1,10 +1,13 @@
package protocol
import (
"fmt"
"reflect"
"strings"
"testing"
"github.com/status-im/status-go/logutils"
"github.com/stretchr/testify/require"
)
@ -157,6 +160,8 @@ func TestReplaceMentions(t *testing.T) {
{"code case 2", "` @user2 `", "` @user2 `"},
{"code case 3", "``` @user2 ```", "``` @user2 ```"},
{"code case 4", "` ` @user2 ``", "` ` @0xpk2 ``"},
{"double @", "@ @user2", "@ @0xpk2"},
}
for _, tt := range tests {
@ -338,18 +343,254 @@ func TestToInputField(t *testing.T) {
}
}
func TestSubs(t *testing.T) {
testCases := []struct {
name string
input string
start int
end int
expected string
}{
{
name: "Normal case",
input: "Hello, world!",
start: 0,
end: 5,
expected: "Hello",
},
{
name: "Start index out of range (negative)",
input: "Hello, world!",
start: -5,
end: 5,
expected: "Hello",
},
{
name: "End index out of range",
input: "Hello, world!",
start: 7,
end: 50,
expected: "world!",
},
{
name: "Start index greater than end index",
input: "Hello, world!",
start: 10,
end: 5,
expected: ", wor",
},
{
name: "Both indices out of range",
input: "Hello, world!",
start: -5,
end: 50,
expected: "Hello, world!",
},
{
name: "Start index negative, end index out of range",
input: "Hello, world!",
start: -10,
end: 15,
expected: "Hello, world!",
},
{
name: "Start index negative, end index within range",
input: "Hello, world!",
start: -10,
end: 5,
expected: "Hello",
},
{
name: "Start index negative, end index negative",
input: "Hello, world!",
start: -10,
end: -5,
expected: "",
},
{
name: "Start index zero, end index zero",
input: "Hello, world!",
start: 0,
end: 0,
expected: "",
},
{
name: "Start index positive, end index zero",
input: "Hello, world!",
start: 3,
end: 0,
expected: "Hel",
},
{
name: "Start index equal to input length",
input: "Hello, world!",
start: 13,
end: 15,
expected: "",
},
{
name: "End index negative",
input: "Hello, world!",
start: 5,
end: -5,
expected: "Hello",
},
{
name: "Start and end indices equal and negative",
input: "Hello, world!",
start: -3,
end: -3,
expected: "",
},
{
name: "Start index greater than input length",
input: "Hello, world!",
start: 15,
end: 20,
expected: "",
},
{
name: "End index equal to input length",
input: "Hello, world!",
start: 0,
end: 13,
expected: "Hello, world!",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actual := subs(tc.input, tc.start, tc.end)
if actual != tc.expected {
t.Errorf("Test case '%s': expected '%s', got '%s'", tc.name, tc.expected, actual)
}
})
}
}
func TestLastIndexOf(t *testing.T) {
atSignIdx := LastIndexOf("@", charAtSign, 0)
atSignIdx := lastIndexOf("@", charAtSign, 0)
require.Equal(t, 0, atSignIdx)
atSignIdx = LastIndexOf("@@", charAtSign, 1)
atSignIdx = lastIndexOf("@@", charAtSign, 1)
require.Equal(t, 1, atSignIdx)
//at-sign-idx 0 text @t searched-text t start 2 end 2 new-text
atSignIdx = LastIndexOf("@t", charAtSign, 2)
atSignIdx = lastIndexOf("@t", charAtSign, 2)
require.Equal(t, 0, atSignIdx)
}
func TestDiffText(t *testing.T) {
testCases := []struct {
oldText string
newText string
expected *TextDiff
}{
{
oldText: "",
newText: "A",
expected: &TextDiff{
start: 0,
end: 0,
previousText: "",
newText: "A",
},
},
{
oldText: "A",
newText: "Ab",
expected: &TextDiff{
start: 1,
end: 1,
previousText: "A",
newText: "b",
},
},
{
oldText: "Ab",
newText: "Abc",
expected: &TextDiff{
start: 2,
end: 2,
previousText: "Ab",
newText: "c",
},
},
{
oldText: "Abc",
newText: "Ac",
expected: &TextDiff{
start: 1,
end: 2,
previousText: "Abc",
newText: "",
},
},
{
oldText: "Ac",
newText: "Adc",
expected: &TextDiff{
start: 1,
end: 1,
previousText: "Ac",
newText: "d",
},
},
{
oldText: "Adc",
newText: "Ad ee c",
expected: &TextDiff{
start: 2,
end: 2,
previousText: "Adc",
newText: " ee ",
},
},
{
oldText: "Ad ee c",
newText: "A fff d ee c",
expected: &TextDiff{
start: 1,
end: 1,
previousText: "Ad ee c",
newText: " fff ",
},
},
{
oldText: "A fff d ee c",
newText: " fff d ee c",
expected: &TextDiff{
start: 0,
end: 1,
previousText: "A fff d ee c",
newText: "",
},
},
{
oldText: " fff d ee c",
newText: " fffee c",
expected: &TextDiff{
start: 4,
end: 7,
previousText: " fff d ee c",
newText: "",
},
},
{
oldText: "abc",
newText: "abc",
expected: nil,
},
}
for i, tc := range testCases {
t.Run(fmt.Sprintf("%d", i+1), func(t *testing.T) {
diff := diffText(tc.oldText, tc.newText)
require.Equal(t, tc.expected, diff)
})
}
}
type MockMentionableUserGetter struct {
mentionableUserMap map[string]*MentionableUser
}
@ -362,67 +603,127 @@ func (m *MockMentionableUserGetter) getMentionableUser(chatID string, pk string)
return m.mentionableUserMap[pk], nil
}
func TestMentionSuggestion(t *testing.T) {
mentionableUserMap, chatID, mentionManager := setupMentionSuggestionTest()
func TestMentionSuggestionCases(t *testing.T) {
mentionableUserMap, chatID, mentionManager := setupMentionSuggestionTest(nil)
testCases := []struct {
newText string
prevText string
start int
end int
inputText string
expectedSize int
}{
{"@", "", 0, 0, "@", len(mentionableUserMap)},
{"u", "", 1, 1, "@u", len(mentionableUserMap)},
{"2", "", 2, 2, "@u2", 1},
{"3", "", 3, 3, "@u23", 0},
{"", "3", 3, 4, "@u2", 1},
{"@", len(mentionableUserMap)},
{"@u", len(mentionableUserMap)},
{"@u2", 1},
{"@u23", 0},
{"@u2", 1},
}
for _, tc := range testCases {
input := MentionState{PreviousText: tc.prevText, NewText: &tc.newText, Start: tc.start, End: tc.end}
_, err := mentionManager.OnTextInput(chatID, &input)
require.NoError(t, err)
ctx, err := mentionManager.CalculateSuggestions(chatID, tc.inputText)
require.NoError(t, err)
require.Equal(t, tc.expectedSize, len(ctx.MentionSuggestions))
t.Logf("Input: %+v, MentionState:%+v, InputSegments:%+v\n", input, ctx.MentionState, ctx.InputSegments)
for i, tc := range testCases {
t.Run(fmt.Sprintf("%d", i+1), func(t *testing.T) {
_, err := mentionManager.OnChangeText(chatID, tc.inputText)
require.NoError(t, err)
ctx, err := mentionManager.CalculateSuggestions(chatID, tc.inputText)
require.NoError(t, err)
require.Equal(t, tc.expectedSize, len(ctx.MentionSuggestions))
t.Logf("Input: %+v, MentionState:%+v, InputSegments:%+v\n", tc.inputText, ctx.MentionState, ctx.InputSegments)
})
}
}
func TestMentionSuggestionWithSpecialCharacters(t *testing.T) {
mentionableUserMap, chatID, mentionManager := setupMentionSuggestionTest()
func TestMentionSuggestionSpecialInputModeForAndroid(t *testing.T) {
mentionableUserMap, chatID, mentionManager := setupMentionSuggestionTest(nil)
testCases := []struct {
inputText string
expectedSize int
}{
{"A", 0},
{"As", 0},
{"Asd", 0},
{"Asd@", len(mentionableUserMap)},
}
for i, tc := range testCases {
t.Run(fmt.Sprintf("%d", i+1), func(t *testing.T) {
ctx, err := mentionManager.OnChangeText(chatID, tc.inputText)
require.NoError(t, err)
require.Equal(t, tc.expectedSize, len(ctx.MentionSuggestions))
t.Logf("Input: %+v, MentionState:%+v, InputSegments:%+v\n", tc.inputText, ctx.MentionState, ctx.InputSegments)
})
}
}
func TestMentionSuggestionSpecialChars(t *testing.T) {
mentionableUserMap, chatID, mentionManager := setupMentionSuggestionTest(nil)
testCases := []struct {
newText string
prevText string
start int
end int
inputText string
expectedSize int
calculateSuggestion bool
}{
{"'", "", 0, 0, "'", 0, false},
{"", "", 0, 1, "", 0, true},
{"@", "", 1, 1, "@", len(mentionableUserMap), true},
{"'", 0, false},
{"", 0, true},
{"@", len(mentionableUserMap), true},
}
for _, tc := range testCases {
input := MentionState{PreviousText: tc.prevText, NewText: &tc.newText, Start: tc.start, End: tc.end}
ctx, err := mentionManager.OnTextInput(chatID, &input)
ctx, err := mentionManager.OnChangeText(chatID, tc.inputText)
require.NoError(t, err)
if tc.calculateSuggestion {
ctx, err = mentionManager.CalculateSuggestions(chatID, tc.inputText)
require.NoError(t, err)
require.Equal(t, tc.expectedSize, len(ctx.MentionSuggestions))
}
t.Logf("Input: %+v, MentionState:%+v, InputSegments:%+v\n", input, ctx.MentionState, ctx.InputSegments)
t.Logf("Input: %+v, MentionState:%+v, InputSegments:%+v\n", tc.inputText, ctx.MentionState, ctx.InputSegments)
}
}
func setupMentionSuggestionTest() (map[string]*MentionableUser, string, *MentionManager) {
mentionableUserMap := getMentionableUserMap()
func TestMentionSuggestionAtSignSpaceCases(t *testing.T) {
mentionableUserMap, chatID, mentionManager := setupMentionSuggestionTest(map[string]*MentionableUser{
"0xpk1": {
primaryName: "User Number One",
Contact: &Contact{
ID: "0xpk1",
},
},
})
testCases := []struct {
inputText string
expectedSize int
calculateSuggestion bool
}{
{"@", len(mentionableUserMap), true},
{"@ ", 0, true},
{"@ @", len(mentionableUserMap), true},
}
var ctx *ChatMentionContext
var err error
for _, tc := range testCases {
ctx, err = mentionManager.OnChangeText(chatID, tc.inputText)
require.NoError(t, err)
t.Logf("After OnChangeText, Input: %+v, MentionState:%+v, InputSegments:%+v\n", tc.inputText, ctx.MentionState, ctx.InputSegments)
if tc.calculateSuggestion {
ctx, err = mentionManager.CalculateSuggestions(chatID, tc.inputText)
require.NoError(t, err)
require.Equal(t, tc.expectedSize, len(ctx.MentionSuggestions))
t.Logf("After CalculateSuggestions, Input: %+v, MentionState:%+v, InputSegments:%+v\n", tc.inputText, ctx.MentionState, ctx.InputSegments)
}
}
require.Len(t, ctx.InputSegments, 3)
require.Equal(t, Mention, ctx.InputSegments[0].Type)
require.Equal(t, "@ @", ctx.InputSegments[0].Value)
require.Equal(t, Text, ctx.InputSegments[1].Type)
require.Equal(t, "@", ctx.InputSegments[1].Value)
require.Equal(t, Text, ctx.InputSegments[2].Type)
require.Equal(t, "@", ctx.InputSegments[2].Value)
}
func setupMentionSuggestionTest(mentionableUserMapInput map[string]*MentionableUser) (map[string]*MentionableUser, string, *MentionManager) {
mentionableUserMap := mentionableUserMapInput
if mentionableUserMap == nil {
mentionableUserMap = getDefaultMentionableUserMap()
}
for _, u := range mentionableUserMap {
addSearchablePhrases(u)
@ -442,12 +743,13 @@ func setupMentionSuggestionTest() (map[string]*MentionableUser, string, *Mention
Messenger: &Messenger{
allChats: allChats,
},
logger: logutils.ZapLogger().Named("MentionManager"),
}
return mentionableUserMap, chatID, mentionManager
}
func getMentionableUserMap() map[string]*MentionableUser {
func getDefaultMentionableUserMap() map[string]*MentionableUser {
return map[string]*MentionableUser{
"0xpk1": {
primaryName: "User Number One",

View File

@ -1302,18 +1302,14 @@ func (api *PublicAPI) ChatMentionCheckMentions(chatID, text string) (string, err
return api.service.messenger.GetMentionsManager().CheckMentions(chatID, text)
}
func (api *PublicAPI) ChatMentionOnTextInput(chatID string, state *protocol.MentionState) (*protocol.ChatMentionContext, error) {
return api.service.messenger.GetMentionsManager().OnTextInput(chatID, state)
func (api *PublicAPI) ChatMentionOnChangeText(chatID, text string) (*protocol.ChatMentionContext, error) {
return api.service.messenger.GetMentionsManager().OnChangeText(chatID, text)
}
func (api *PublicAPI) ChatMentionRecheckAtIdxs(chatID string, text string, publicKey string) (*protocol.ChatMentionContext, error) {
return api.service.messenger.GetMentionsManager().RecheckAtIdxs(chatID, text, publicKey)
}
func (api *PublicAPI) ChatMentionCalculateSuggestions(chatID, text string) (*protocol.ChatMentionContext, error) {
return api.service.messenger.GetMentionsManager().CalculateSuggestions(chatID, text)
}
func (api *PublicAPI) ChatMentionNewInputTextWithMention(chatID, text, primaryName string) *protocol.ChatMentionContext {
return api.service.messenger.GetMentionsManager().NewInputTextWithMention(chatID, text, primaryName)
}