2023-04-07 08:47:38 +00:00
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"
2023-04-27 03:06:40 +00:00
"go.uber.org/zap"
"github.com/status-im/status-go/logutils"
2023-04-07 08:47:38 +00:00
"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
2024-08-30 01:37:08 +00:00
suggestionsLimit = 10
2023-04-07 08:47:38 +00:00
)
var (
specialCharsRegex = regexp . MustCompile ( "[@~\\\\*_\n>`]{1}" )
endingCharsRegex = regexp . MustCompile ( endingChars )
2023-06-21 06:42:14 +00:00
wordRegex = regexp . MustCompile ( "^[\\w\\d\\-]*" + endingChars + "|[\\S]+" )
2023-04-07 08:47:38 +00:00
)
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
}
2023-05-23 10:07:49 +00:00
type searchablePhrase struct {
originalName string
phrase string
}
2023-04-07 08:47:38 +00:00
type MentionableUser struct {
* Contact
2023-05-23 10:07:49 +00:00
searchablePhrases [ ] searchablePhrase
2023-04-07 08:47:38 +00:00
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
2023-06-16 15:09:37 +00:00
PreviousText string
NewText string
Start int
End int
operation textOperation
2023-04-07 08:47:38 +00:00
}
func ( ms * MentionState ) String ( ) string {
atIdxsStr := ""
for i , entry := range ms . AtIdxs {
if i > 0 {
atIdxsStr += ", "
}
atIdxsStr += fmt . Sprintf ( "%+v" , entry )
}
2023-04-27 03:06:40 +00:00
return fmt . Sprintf ( "MentionState{AtSignIdx: %d, AtIdxs: [%s], MentionEnd: %d, PreviousText: %q, NewText: %s, Start: %d, End: %d}" ,
2023-06-16 15:09:37 +00:00
ms . AtSignIdx , atIdxsStr , ms . MentionEnd , ms . PreviousText , ms . NewText , ms . Start , ms . End )
2023-04-07 08:47:38 +00:00
}
type ChatMentionContext struct {
ChatID string
InputSegments [ ] InputSegment
MentionSuggestions map [ string ] * MentionableUser
MentionState * MentionState
2023-04-27 03:06:40 +00:00
PreviousText string // user input text before the last change
2023-04-07 08:47:38 +00:00
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
2023-04-27 03:06:40 +00:00
logger * zap . Logger
2023-04-07 08:47:38 +00:00
}
func NewMentionManager ( m * Messenger ) * MentionManager {
mm := & MentionManager {
mentionContexts : make ( map [ string ] * ChatMentionContext ) ,
Messenger : m ,
2023-04-27 03:06:40 +00:00
logger : logutils . ZapLogger ( ) . Named ( "MentionManager" ) ,
2023-04-07 08:47:38 +00:00
}
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 ) {
2023-06-16 15:09:37 +00:00
mentionableUsers , err := m . mentionableUserGetter . getMentionableUsers ( chatID )
2023-04-07 08:47:38 +00:00
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
}
2023-05-15 07:21:41 +00:00
func ( m * MentionManager ) ReplaceWithPublicKey ( chatID , text string ) ( string , error ) {
2023-04-07 08:47:38 +00:00
chat , _ := m . allChats . Load ( chatID )
if chat == nil {
return "" , fmt . Errorf ( "chat not found when check mentions, chatID: %s" , chatID )
}
2023-06-16 15:09:37 +00:00
mentionableUsers , err := m . mentionableUserGetter . getMentionableUsers ( chatID )
2023-04-07 08:47:38 +00:00
if err != nil {
return "" , err
}
newText := ReplaceMentions ( text , mentionableUsers )
m . ClearMentions ( chatID )
return newText , nil
}
2023-06-16 15:09:37 +00:00
func ( m * MentionManager ) OnChangeText ( chatID , fullText string ) ( * ChatMentionContext , error ) {
2023-04-07 08:47:38 +00:00
ctx := m . getChatMentionContext ( chatID )
2023-06-16 15:09:37 +00:00
diff := diffText ( ctx . PreviousText , fullText )
2023-04-27 03:06:40 +00:00
if diff == nil {
return ctx , nil
}
2023-06-16 15:09:37 +00:00
ctx . PreviousText = fullText
2023-04-27 03:06:40 +00:00
if ctx . MentionState == nil {
ctx . MentionState = & MentionState { }
}
ctx . MentionState . PreviousText = diff . previousText
2023-06-16 15:09:37 +00:00
ctx . MentionState . NewText = diff . newText
2023-04-27 03:06:40 +00:00
ctx . MentionState . Start = diff . start
ctx . MentionState . End = diff . end
2023-06-16 15:09:37 +00:00
ctx . MentionState . operation = diff . operation
2023-04-27 03:06:40 +00:00
2023-06-19 11:08:45 +00:00
atIndexes , err := calculateAtIndexEntries ( ctx . MentionState )
if err != nil {
return ctx , err
}
ctx . MentionState . AtIdxs = atIndexes
2023-04-27 03:06:40 +00:00
m . logger . Debug ( "OnChangeText" , zap . String ( "chatID" , chatID ) , zap . Any ( "state" , ctx . MentionState ) )
2023-06-16 15:09:37 +00:00
return m . calculateSuggestions ( chatID , fullText )
2023-04-07 08:47:38 +00:00
}
2023-06-16 15:09:37 +00:00
func ( m * MentionManager ) calculateSuggestions ( chatID , fullText string ) ( * ChatMentionContext , error ) {
2023-04-07 08:47:38 +00:00
ctx := m . getChatMentionContext ( chatID )
mentionableUsers , err := m . mentionableUserGetter . getMentionableUsers ( chatID )
if err != nil {
return nil , err
}
2023-06-16 15:09:37 +00:00
m . logger . Debug ( "calculateSuggestions" , zap . String ( "chatID" , chatID ) , zap . String ( "fullText" , fullText ) , zap . Int ( "num of mentionable user" , len ( mentionableUsers ) ) )
2023-04-07 08:47:38 +00:00
2023-06-16 15:09:37 +00:00
m . calculateSuggestionsWithMentionableUsers ( chatID , fullText , mentionableUsers )
2023-04-07 08:47:38 +00:00
return ctx , nil
}
2023-06-16 15:09:37 +00:00
func ( m * MentionManager ) calculateSuggestionsWithMentionableUsers ( chatID string , fullText string , mentionableUsers map [ string ] * MentionableUser ) {
2023-04-07 08:47:38 +00:00
ctx := m . getChatMentionContext ( chatID )
state := ctx . MentionState
if len ( state . AtIdxs ) == 0 {
state . AtIdxs = nil
ctx . MentionSuggestions = nil
ctx . InputSegments = [ ] InputSegment { {
Type : Text ,
2023-06-16 15:09:37 +00:00
Value : fullText ,
2023-04-07 08:47:38 +00:00
} }
return
}
2023-06-16 15:09:37 +00:00
newAtIndexEntries := checkIdxForMentions ( fullText , state . AtIdxs , mentionableUsers )
2023-06-19 11:08:45 +00:00
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 ) )
}
2023-06-16 15:09:37 +00:00
2023-04-07 08:47:38 +00:00
var end int
2023-06-16 15:09:37 +00:00
switch state . operation {
case textOperationAdd :
end = state . Start + len ( [ ] rune ( state . NewText ) )
case textOperationDelete :
2023-04-07 08:47:38 +00:00
end = state . Start
2023-06-16 15:09:37 +00:00
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 ) )
2023-04-07 08:47:38 +00:00
}
2023-06-21 06:42:14 +00:00
atSignIdx := lastIndexOfAtSign ( fullText , end )
2023-04-07 08:47:38 +00:00
var suggestions map [ string ] * MentionableUser
2023-06-16 15:09:37 +00:00
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 {
2024-08-30 01:37:08 +00:00
suggestions = getUserSuggestions ( mentionableUsers , searchedText , suggestionsLimit )
2023-06-16 15:09:37 +00:00
}
2023-04-07 08:47:38 +00:00
}
state . AtSignIdx = atSignIdx
2023-06-16 15:09:37 +00:00
state . AtIdxs = newAtIndexEntries
2023-04-07 08:47:38 +00:00
state . MentionEnd = end
ctx . InputSegments = calculatedInput
ctx . MentionSuggestions = suggestions
}
2023-05-15 07:21:41 +00:00
func ( m * MentionManager ) SelectMention ( chatID , text , primaryName , publicKey string ) ( * ChatMentionContext , error ) {
2023-04-07 08:47:38 +00:00
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 : ] )
2023-05-15 07:21:41 +00:00
2023-07-25 08:47:24 +00:00
ctx , err := m . OnChangeText ( chatID , ctx . NewText )
2023-06-16 15:09:37 +00:00
if err != nil {
return nil , err
}
m . clearSuggestions ( chatID )
return ctx , nil
2023-04-07 08:47:38 +00:00
}
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 = ""
2023-04-27 03:06:40 +00:00
ctx . PreviousText = ""
2023-04-07 08:47:38 +00:00
m . clearSuggestions ( chatID )
}
func ( m * MentionManager ) ToInputField ( chatID , text string ) ( * ChatMentionContext , error ) {
2023-06-16 15:09:37 +00:00
mentionableUsers , err := m . mentionableUserGetter . getMentionableUsers ( chatID )
2023-04-07 08:47:38 +00:00
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
2023-05-15 12:15:10 +00:00
ctx . PreviousText = newText
2023-04-07 08:47:38 +00:00
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
2023-05-23 10:07:49 +00:00
for _ , p := range user . searchablePhrases {
if searchedText == "" || strings . HasPrefix ( strings . ToLower ( p . phrase ) , searchedText ) {
match = p . originalName
2023-04-07 08:47:38 +00:00
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
2023-05-23 10:07:49 +00:00
for _ , name := range user . names ( ) {
if hasMatchingPrefix ( name , searchedText ) {
match = name
}
2023-04-07 08:47:38 +00:00
}
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 {
2023-05-23 10:07:49 +00:00
regexStr := ""
for i , name := range user . names ( ) {
2023-06-19 11:08:45 +00:00
if name == "" {
continue
}
2023-05-23 10:07:49 +00:00
name = strings . ToLower ( name )
if i != 0 {
regexStr += "|"
}
regexStr += "^" + name + endingChars + "|" + "^" + name + "$"
}
2023-04-07 08:47:38 +00:00
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 )
2023-07-25 08:47:24 +00:00
if nextWordIdx >= len ( tr ) {
return nil
}
2023-04-07 08:47:38 +00:00
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 ] )
}
2024-08-30 01:37:08 +00:00
userSuggestions := getUserSuggestions ( users , searchedText , suggestionsLimit )
2023-04-07 08:47:38 +00:00
userSuggestionsCnt := len ( userSuggestions )
switch {
case userSuggestionsCnt == 0 :
return nil
case userSuggestionsCnt == 1 :
user := getFirstUser ( userSuggestions )
2023-06-19 11:08:45 +00:00
// maybe len(users) == 1 and user input `@` so we need to recheck if the user is really mentioned
2023-04-07 08:47:38 +00:00
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 {
2023-06-08 06:47:38 +00:00
user := matchMention ( text , users , mentionKeyIdx , nextWordStart , newWords )
if user != nil {
return user
}
2023-04-07 08:47:38 +00:00
}
2023-06-08 06:47:38 +00:00
return filterWithFullMatch ( userSuggestions , searchedText )
2023-04-07 08:47:38 +00:00
}
}
return nil
}
2023-06-08 06:47:38 +00:00
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 )
}
2023-04-07 08:47:38 +00:00
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 {
2023-05-23 10:07:49 +00:00
searchablePhrases := user . names ( )
2023-04-07 08:47:38 +00:00
for _ , s := range searchablePhrases {
if s != "" {
newWords := [ ] string { s }
newWords = append ( newWords , strings . Split ( s , " " ) [ 1 : ] ... )
2023-05-23 10:07:49 +00:00
var phrases [ ] searchablePhrase
for _ , w := range newWords {
phrases = append ( phrases , searchablePhrase { s , w } )
}
user . searchablePhrases = append ( user . searchablePhrases , phrases ... )
2023-04-07 08:47:38 +00:00
}
}
return user
}
return nil
}
type AtIndexEntry struct {
From int
To int
Checked bool
Mentioned bool
NextAtIdx int
}
2023-06-16 15:09:37 +00:00
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 )
}
2023-06-19 11:08:45 +00:00
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 )
2023-04-07 08:47:38 +00:00
}
2023-06-19 11:08:45 +00:00
oldLen := len ( oldRunes )
newLen := len ( newRunes )
diff := newLen - oldLen
oldAtSignIndexes := getAtSignIdxs ( string ( oldRunes ) , state . Start )
newAtSignIndexes := getAtSignIdxs ( state . NewText , state . Start )
2023-04-07 08:47:38 +00:00
for _ , entry := range state . AtIdxs {
2023-06-19 11:08:45 +00:00
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
}
2023-07-25 08:47:24 +00:00
if entry . From < state . Start && entry . To + 1 >= state . Start { // impacted after user edit so need to be rechecked
2023-06-19 11:08:45 +00:00
entry . Checked = false
}
keptAtIndexEntries = append ( keptAtIndexEntries , entry )
2023-04-07 08:47:38 +00:00
}
}
2023-06-19 11:08:45 +00:00
return addNewAtSignIndexes ( keptAtIndexEntries , newAtSignIndexes ) , nil
}
2023-04-07 08:47:38 +00:00
2023-06-19 11:08:45 +00:00
func addNewAtSignIndexes ( keptAtIdxs [ ] * AtIndexEntry , newAtSignIndexes [ ] int ) [ ] * AtIndexEntry {
2023-06-16 15:09:37 +00:00
var newAtIndexEntries [ ] * AtIndexEntry
2023-04-07 08:47:38 +00:00
var added bool
2023-06-19 11:08:45 +00:00
var lastNewIdx int
newAtSignIndexesCount := len ( newAtSignIndexes )
2023-06-16 15:09:37 +00:00
if newAtSignIndexesCount > 0 {
2023-06-19 11:08:45 +00:00
lastNewIdx = newAtSignIndexes [ newAtSignIndexesCount - 1 ]
2023-06-16 15:09:37 +00:00
}
2023-04-07 08:47:38 +00:00
for _ , entry := range keptAtIdxs {
2023-06-19 11:08:45 +00:00
if newAtSignIndexesCount > 0 && ! added && entry . From > lastNewIdx {
2023-06-16 15:09:37 +00:00
newAtIndexEntries = append ( newAtIndexEntries , makeAtIdxs ( newAtSignIndexes ) ... )
newAtIndexEntries = append ( newAtIndexEntries , entry )
2023-04-07 08:47:38 +00:00
added = true
} else {
2023-06-16 15:09:37 +00:00
newAtIndexEntries = append ( newAtIndexEntries , entry )
2023-04-07 08:47:38 +00:00
}
}
if ! added {
2023-06-16 15:09:37 +00:00
newAtIndexEntries = append ( newAtIndexEntries , makeAtIdxs ( newAtSignIndexes ) ... )
2023-04-07 08:47:38 +00:00
}
2023-06-16 15:09:37 +00:00
return newAtIndexEntries
2023-04-07 08:47:38 +00:00
}
func makeAtIdxs ( idxs [ ] int ) [ ] * AtIndexEntry {
result := make ( [ ] * AtIndexEntry , len ( idxs ) )
for i , idx := range idxs {
result [ i ] = & AtIndexEntry {
From : idx ,
Checked : false ,
}
}
return result
}
2023-06-16 15:09:37 +00:00
// 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 { } )
2023-04-07 08:47:38 +00:00
}
2023-06-16 15:09:37 +00:00
func getAtSignIdxsHelper ( text string , delta int , from int , idxs [ ] int ) [ ] int {
2023-04-07 08:47:38 +00:00
tr := [ ] rune ( text )
2023-06-21 06:42:14 +00:00
idx := strings . IndexRune ( string ( tr [ from : ] ) , '@' )
2023-04-07 08:47:38 +00:00
if idx != - 1 {
2023-06-21 06:42:14 +00:00
idx = utf8 . RuneCountInString ( text [ : idx ] )
2023-04-07 08:47:38 +00:00
idx += from
2023-06-16 15:09:37 +00:00
idxs = append ( idxs , delta + idx )
return getAtSignIdxsHelper ( text , delta , idx + 1 , idxs )
2023-04-07 08:47:38 +00:00
}
return idxs
}
2023-06-16 15:09:37 +00:00
func checkAtIndexEntry ( fullText string , entry * AtIndexEntry , mentionableUsers map [ string ] * MentionableUser ) * AtIndexEntry {
2023-04-07 08:47:38 +00:00
if entry . Checked {
return entry
}
2023-06-19 11:08:45 +00:00
result := MatchMention ( fullText , mentionableUsers , entry . From )
2023-04-07 08:47:38 +00:00
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 ,
2023-06-16 15:09:37 +00:00
To : len ( [ ] rune ( fullText ) ) ,
2023-04-07 08:47:38 +00:00
Checked : true ,
}
}
2023-06-16 15:09:37 +00:00
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
2023-04-07 08:47:38 +00:00
}
if previousEntryIdx >= 0 {
2023-06-16 15:09:37 +00:00
newIndexEntries [ previousEntryIdx ] . NextAtIdx = entry . From
2023-04-07 08:47:38 +00:00
}
newEntry . NextAtIdx = intUnknown
2023-06-16 15:09:37 +00:00
newIndexEntries = append ( newIndexEntries , newEntry )
2023-04-07 08:47:38 +00:00
}
2023-06-16 15:09:37 +00:00
if len ( newIndexEntries ) > 0 {
lastIdx := len ( newIndexEntries ) - 1
if newIndexEntries [ lastIdx ] . Mentioned {
return newIndexEntries
2023-04-07 08:47:38 +00:00
}
2023-06-16 15:09:37 +00:00
newIndexEntries [ lastIdx ] . To = len ( [ ] rune ( fullText ) ) - 1
newIndexEntries [ lastIdx ] . Checked = false
return newIndexEntries
2023-04-07 08:47:38 +00:00
}
return nil
}
2023-06-19 11:08:45 +00:00
func appendInputSegment ( result * [ ] InputSegment , typ SegmentType , value string , fullText * string ) {
if value != "" {
* result = append ( * result , InputSegment { Type : typ , Value : value } )
* fullText += value
2023-04-07 08:47:38 +00:00
}
2023-06-19 11:08:45 +00:00
}
2023-04-07 08:47:38 +00:00
2023-06-19 11:08:45 +00:00
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
2023-04-07 08:47:38 +00:00
2023-06-19 11:08:45 +00:00
var result [ ] InputSegment
fullText := ""
if atIndexEntries [ 0 ] . From != 0 {
t := subs ( text , 0 , atIndexEntries [ 0 ] . From )
appendInputSegment ( & result , Text , t , & fullText )
2023-04-07 08:47:38 +00:00
}
2023-06-19 11:08:45 +00:00
for _ , entry := range atIndexEntries {
2023-04-07 08:47:38 +00:00
from := entry . From
to := entry . To
nextAtIdx := entry . NextAtIdx
mentioned := entry . Mentioned
if mentioned && nextAtIdx != intUnknown {
2023-06-19 11:08:45 +00:00
t := subs ( text , from , to + 1 )
appendInputSegment ( & result , Mention , t , & fullText )
t = subs ( text , to + 1 , nextAtIdx )
appendInputSegment ( & result , Text , t , & fullText )
2023-04-07 08:47:38 +00:00
} else if mentioned && lastFrom == from {
2023-06-19 11:08:45 +00:00
t := subs ( text , from , to + 1 )
appendInputSegment ( & result , Mention , t , & fullText )
t = subs ( text , to + 1 )
appendInputSegment ( & result , Text , t , & fullText )
2023-04-07 08:47:38 +00:00
} else {
2023-06-19 11:08:45 +00:00
t := subs ( text , from , to + 1 )
appendInputSegment ( & result , Text , t , & fullText )
2023-04-07 08:47:38 +00:00
}
}
2023-06-19 11:08:45 +00:00
return result , fullText == text
2023-04-07 08:47:38 +00:00
}
2023-04-27 03:06:40 +00:00
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 ] )
}
2023-04-07 08:47:38 +00:00
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 : "" ,
2023-06-16 15:09:37 +00:00
NewText : newText ,
2023-04-07 08:47:38 +00:00
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 )
2023-06-16 15:09:37 +00:00
state . NewText = string ( tr [ len ( tr ) - 1 ] )
2023-04-07 08:47:38 +00:00
state . Start += len ( tr )
state . End += len ( tr )
}
return state
}
2023-06-21 06:42:14 +00:00
// lastIndexOfAtSign returns the index of the last occurrence of substr in s starting from index start.
2023-04-07 08:47:38 +00:00
// If substr is not present in s, it returns -1.
2023-06-21 06:42:14 +00:00
func lastIndexOfAtSign ( s string , start int ) int {
2023-04-07 08:47:38 +00:00
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 ] )
2023-06-21 06:42:14 +00:00
idx := strings . IndexRune ( reversedS , '@' )
2023-04-07 08:47:38 +00:00
if idx == - 1 {
return - 1
}
// Calculate the index in the original string.
2023-06-21 06:42:14 +00:00
idx = utf8 . RuneCountInString ( reversedS [ : idx ] )
return start - idx
2023-04-07 08:47:38 +00:00
}
// 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 )
}
2023-04-27 03:06:40 +00:00
2023-06-16 15:09:37 +00:00
type textOperation int
const (
textOperationAdd textOperation = iota + 1
textOperationDelete
textOperationReplace
)
2023-04-27 03:06:40 +00:00
type TextDiff struct {
previousText string
2023-06-16 15:09:37 +00:00
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
2023-04-27 03:06:40 +00:00
}
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 {
2023-06-16 15:09:37 +00:00
return & TextDiff { previousText : oldText , newText : newText , start : 0 , end : 0 , operation : textOperationAdd }
2023-04-27 03:06:40 +00:00
}
if newLen == 0 {
2023-06-16 15:09:37 +00:00
return & TextDiff { previousText : oldText , newText : "" , start : 0 , end : oldLen , operation : textOperationReplace }
2023-04-27 03:06:40 +00:00
}
// 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 }
2023-06-16 15:09:37 +00:00
if newLen > oldLen && ( start == oldLen || oldEnd == 0 || start == oldEnd ) {
diff . operation = textOperationAdd
2023-04-27 03:06:40 +00:00
diff . end = start
diff . newText = string ( t2 [ start : newEnd ] )
2023-06-16 15:09:37 +00:00
} else if newLen < oldLen && ( start == newLen || newEnd == 0 || start == newEnd ) {
diff . operation = textOperationDelete
diff . end = oldEnd - 1
2023-04-27 03:06:40 +00:00
} else {
2023-06-16 15:09:37 +00:00
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 ] )
2023-04-27 03:06:40 +00:00
}
}
return diff
}