status-im/status-go

View on GitHub
protocol/chat.go

Summary

Maintainability
A
0 mins
Test Coverage
B
85%
package protocol

import (
    "crypto/ecdsa"
    "encoding/json"
    "errors"
    "math/rand"
    "strings"
    "time"

    "github.com/status-im/status-go/deprecation"
    "github.com/status-im/status-go/eth-node/crypto"
    "github.com/status-im/status-go/eth-node/types"
    userimage "github.com/status-im/status-go/images"
    "github.com/status-im/status-go/protocol/common"
    "github.com/status-im/status-go/protocol/communities"
    "github.com/status-im/status-go/protocol/protobuf"
    "github.com/status-im/status-go/protocol/requests"
    v1protocol "github.com/status-im/status-go/protocol/v1"
    "github.com/status-im/status-go/services/utils"
)

var chatColors = []string{
    "#fa6565", // red
    "#887af9", // blue
    "#FE8F59", // orange
    "#7cda00", // green
    "#51d0f0", // light-blue
    "#d37ef4", // purple
}

type ChatType int

type ChatContext string

const (
    ChatTypeOneToOne ChatType = iota + 1
    ChatTypePublic
    ChatTypePrivateGroupChat
    // Deprecated: CreateProfileChat shouldn't be used
    // and is only left here in case profile chat feature is re-introduced.
    ChatTypeProfile
    // Deprecated: ChatTypeTimeline shouldn't be used
    // and is only left here in case profile chat feature is re-introduced.
    ChatTypeTimeline
    ChatTypeCommunityChat
)

const (
    FirstMessageTimestampUndefined = 0
    FirstMessageTimestampNoMessage = 1
)

const (
    MuteFor1MinDuration   = time.Minute
    MuteFor15MinsDuration = 15 * time.Minute
    MuteFor1HrsDuration   = time.Hour
    MuteFor8HrsDuration   = 8 * time.Hour
    MuteFor24HrsDuration  = 24 * time.Hour
    MuteFor1WeekDuration  = 7 * 24 * time.Hour
)

// NOTE: Add items to the end of the list, because desktop and mobile
// use this enum by number rater than by string.
const (
    MuteFor15Min requests.MutingVariation = iota + 1
    MuteFor1Hr
    MuteFor8Hr
    MuteFor1Week
    MuteTillUnmuted
    MuteTill1Min
    Unmuted
    MuteFor24Hr
)

const pkStringLength = 68

// timelineChatID is a magic constant id for your own timeline
// Deprecated: timeline chats are no more supported
const timelineChatID = "@timeline70bd746ddcc12beb96b2c9d572d0784ab137ffc774f5383e50585a932080b57cca0484b259e61cecbaa33a4c98a300a"

type Chat struct {
    // ID is the id of the chat, for public chats it is the name e.g. status, for one-to-one
    // is the hex encoded public key and for group chats is a random uuid appended with
    // the hex encoded pk of the creator of the chat
    ID          string `json:"id"`
    Name        string `json:"name"`
    Description string `json:"description"`
    Color       string `json:"color"`
    Emoji       string `json:"emoji"`
    // Active indicates whether the chat has been soft deleted
    Active bool `json:"active"`

    // ViewersCanPostReactions indicates whether users can post reactions in view only mode
    ViewersCanPostReactions bool `json:"viewersCanPostReactions"`

    ChatType ChatType `json:"chatType"`

    // Timestamp indicates the last time this chat has received/sent a message
    Timestamp int64 `json:"timestamp"`
    // LastClockValue indicates the last clock value to be used when sending messages
    LastClockValue uint64 `json:"lastClockValue"`
    // DeletedAtClockValue indicates the clock value at time of deletion, messages
    // with lower clock value of this should be discarded
    DeletedAtClockValue uint64 `json:"deletedAtClockValue"`
    // ReadMessagesAtClockValue indicates the clock value of time till all
    // messages are considered as read
    ReadMessagesAtClockValue uint64
    // Denormalized fields
    UnviewedMessagesCount uint            `json:"unviewedMessagesCount"`
    UnviewedMentionsCount uint            `json:"unviewedMentionsCount"`
    LastMessage           *common.Message `json:"lastMessage"`

    // Group chat fields
    // Members are the members who have been invited to the group chat
    Members []ChatMember `json:"members"`
    // MembershipUpdates is all the membership events in the chat
    MembershipUpdates []v1protocol.MembershipUpdateEvent `json:"membershipUpdateEvents"`

    // Generated username name of the chat for one-to-ones
    Alias string `json:"alias,omitempty"`
    // Identicon generated from public key
    Identicon string `json:"identicon"`

    // Muted is used to check whether we want to receive
    // push notifications for this chat
    Muted bool `json:"muted"`

    // Time in which chat was muted
    MuteTill time.Time `json:"muteTill,omitempty"`

    // Public key of administrator who created invitation link
    InvitationAdmin string `json:"invitationAdmin,omitempty"`

    // Public key of administrator who sent us group invitation
    ReceivedInvitationAdmin string `json:"receivedInvitationAdmin,omitempty"`

    // Public key of user profile
    Profile string `json:"profile,omitempty"`

    // CommunityID is the id of the community it belongs to
    CommunityID string `json:"communityId,omitempty"`

    // CategoryID is the id of the community category this chat belongs to.
    CategoryID string `json:"categoryId,omitempty"`

    // Joined is a timestamp that indicates when the chat was joined
    Joined int64 `json:"joined,omitempty"`

    // SyncedTo is the time up until it has synced with a mailserver
    SyncedTo uint32 `json:"syncedTo,omitempty"`

    // SyncedFrom is the time from when it was synced with a mailserver
    SyncedFrom uint32 `json:"syncedFrom,omitempty"`

    // FirstMessageTimestamp is the time when first message was sent/received on the chat
    // valid only for community chats
    // 0 - undefined
    // 1 - no messages
    FirstMessageTimestamp uint32 `json:"firstMessageTimestamp,omitempty"`

    // Highlight is used for highlight chats
    Highlight bool `json:"highlight,omitempty"`

    // Image of the chat in Base64 format
    Base64Image string `json:"image,omitempty"`

    // If true, the chat is invisible if permissions are not met
    HideIfPermissionsNotMet bool `json:"hideIfPermissionsNotMet,omitempty"`
}

type ChatPreview struct {
    // ID is the id of the chat, for public chats it is the name e.g. status, for one-to-one
    // is the hex encoded public key and for group chats is a random uuid appended with
    // the hex encoded pk of the creator of the chat
    ID          string `json:"id"`
    Name        string `json:"name"`
    Description string `json:"description"`
    Color       string `json:"color"`
    Emoji       string `json:"emoji"`
    // Active indicates whether the chat has been soft deleted
    Active bool `json:"active"`

    ChatType ChatType `json:"chatType"`

    // Timestamp indicates the last time this chat has received/sent a message
    Timestamp int64 `json:"timestamp"`
    // LastClockValue indicates the last clock value to be used when sending messages
    LastClockValue uint64 `json:"lastClockValue"`
    // DeletedAtClockValue indicates the clock value at time of deletion, messages
    // with lower clock value of this should be discarded
    DeletedAtClockValue uint64 `json:"deletedAtClockValue"`

    // Denormalized fields
    UnviewedMessagesCount uint `json:"unviewedMessagesCount"`
    UnviewedMentionsCount uint `json:"unviewedMentionsCount"`

    // Generated username name of the chat for one-to-ones
    Alias string `json:"alias,omitempty"`
    // Identicon generated from public key
    Identicon string `json:"identicon"`

    // Muted is used to check whether we want to receive
    // push notifications for this chat
    Muted bool `json:"muted,omitempty"`

    // Time in which chat will be  ummuted
    MuteTill time.Time `json:"muteTill,omitempty"`

    // Public key of user profile
    Profile string `json:"profile,omitempty"`

    // CommunityID is the id of the community it belongs to
    CommunityID string `json:"communityId,omitempty"`

    // CategoryID is the id of the community category this chat belongs to.
    CategoryID string `json:"categoryId,omitempty"`

    // Joined is a timestamp that indicates when the chat was joined
    Joined int64 `json:"joined,omitempty"`

    // SyncedTo is the time up until it has synced with a mailserver
    SyncedTo uint32 `json:"syncedTo,omitempty"`

    // SyncedFrom is the time from when it was synced with a mailserver
    SyncedFrom uint32 `json:"syncedFrom,omitempty"`

    // ParsedText is the parsed markdown for displaying
    ParsedText json.RawMessage `json:"parsedText,omitempty"`

    Text string `json:"text,omitempty"`

    ContentType protobuf.ChatMessage_ContentType `json:"contentType,omitempty"`

    // Highlight is used for highlight chats
    Highlight bool `json:"highlight,omitempty"`

    // Used for display invited community's name in the last message
    ContentCommunityID string `json:"contentCommunityId,omitempty"`

    // Members array to represent how many there are for chats preview of group chats
    Members []ChatMember `json:"members"`

    OutgoingStatus   string `json:"outgoingStatus,omitempty"`
    ResponseTo       string `json:"responseTo"`
    AlbumImagesCount uint32 `json:"albumImagesCount,omitempty"`
    From             string `json:"from"`
    Deleted          bool   `json:"deleted"`
    DeletedForMe     bool   `json:"deletedForMe"`

    // Image of the chat in Base64 format
    Base64Image string `json:"image,omitempty"`
}

func (c *Chat) PublicKey() (*ecdsa.PublicKey, error) {
    // For one to one chatID is an encoded public key
    if c.ChatType != ChatTypeOneToOne {
        return nil, nil
    }
    return common.HexToPubkey(c.ID)
}

func (c *Chat) Public() bool {
    return c.ChatType == ChatTypePublic
}

// Deprecated: ProfileUpdates shouldn't be used
// and is only left here in case profile chat feature is re-introduced.
func (c *Chat) ProfileUpdates() bool {
    return c.ChatType == ChatTypeProfile || len(c.Profile) > 0
}

// Deprecated: Timeline shouldn't be used
// and is only left here in case profile chat feature is re-introduced.
func (c *Chat) Timeline() bool {
    return c.ChatType == ChatTypeTimeline
}

func (c *Chat) OneToOne() bool {
    return c.ChatType == ChatTypeOneToOne
}

func (c *Chat) CommunityChat() bool {
    return c.ChatType == ChatTypeCommunityChat
}

func (c *Chat) PrivateGroupChat() bool {
    return c.ChatType == ChatTypePrivateGroupChat
}

func (c *Chat) IsActivePersonalChat() bool {
    return c.Active && (c.OneToOne() || c.PrivateGroupChat() || c.Public()) && c.CommunityID == ""
}

// DefaultResendType returns the resend type for a chat.
// This function currently infers the ResendType from the chat type.
// Note that specific message might have different resent types. At times
// some messages dictate their ResendType based on their own properties and
// context, rather than the chat type it is associated with.
func (c *Chat) DefaultResendType() common.ResendType {
    if c.OneToOne() || c.PrivateGroupChat() {
        return common.ResendTypeDataSync
    }

    return common.ResendTypeRawMessage
}

func (c *Chat) shouldBeSynced() bool {
    isPublicChat := !c.Timeline() && !c.ProfileUpdates() && c.Public()
    return isPublicChat || c.OneToOne() || c.PrivateGroupChat()
}

func (c *Chat) CommunityChatID() string {
    if c.ChatType != ChatTypeCommunityChat {
        return c.ID
    }

    // Strips out the local prefix of the community-id
    return c.ID[pkStringLength:]
}

func (c *Chat) Validate() error {
    if c.ID == "" {
        return errors.New("chatID can't be blank")
    }

    if c.OneToOne() {
        _, err := c.PublicKey()
        return err
    }
    return nil
}

func (c *Chat) MembersAsPublicKeys() ([]*ecdsa.PublicKey, error) {
    publicKeys := make([]string, len(c.Members))
    for idx, item := range c.Members {
        publicKeys[idx] = item.ID
    }
    return stringSliceToPublicKeys(publicKeys)
}

func (c *Chat) HasMember(memberID string) bool {
    for _, member := range c.Members {
        if memberID == member.ID {
            return true
        }
    }

    return false
}

func (c *Chat) RemoveMember(memberID string) {
    members := c.Members
    c.Members = []ChatMember{}
    for _, member := range members {
        if memberID != member.ID {
            c.Members = append(c.Members, member)
        }
    }
}

func (c *Chat) updateChatFromGroupMembershipChanges(g *v1protocol.Group) {

    // ID
    c.ID = g.ChatID()

    // Name
    c.Name = g.Name()

    // Color
    color := g.Color()
    if color != "" {
        c.Color = g.Color()
    }

    // Image
    base64Image, err := userimage.GetPayloadDataURI(g.Image())
    if err == nil {
        c.Base64Image = base64Image
    }

    // Members
    members := g.Members()
    admins := g.Admins()
    chatMembers := make([]ChatMember, 0, len(members))
    for _, m := range members {

        chatMember := ChatMember{
            ID: m,
        }
        chatMember.Admin = stringSliceContains(admins, m)
        chatMembers = append(chatMembers, chatMember)
    }
    c.Members = chatMembers

    // MembershipUpdates
    c.MembershipUpdates = g.Events()
}

// NextClockAndTimestamp returns the next clock value
// and the current timestamp
func (c *Chat) NextClockAndTimestamp(timesource common.TimeSource) (uint64, uint64) {
    clock := c.LastClockValue
    timestamp := timesource.GetCurrentTime()
    if clock == 0 || clock < timestamp {
        clock = timestamp
    } else {
        clock = clock + 1
    }
    c.LastClockValue = clock

    return clock, timestamp
}

func (c *Chat) UpdateFromMessage(message *common.Message, timesource common.TimeSource) error {
    c.Timestamp = int64(timesource.GetCurrentTime())

    // If the clock of the last message is lower, we set the message
    if c.LastMessage == nil || c.LastMessage.Clock <= message.Clock {
        c.LastMessage = message
    }
    // If the clock is higher we set the clock
    if c.LastClockValue < message.Clock {
        c.LastClockValue = message.Clock
    }
    return nil
}

func (c *Chat) UpdateFirstMessageTimestamp(timestamp uint32) bool {
    if timestamp == c.FirstMessageTimestamp {
        return false
    }

    // Do not allow to assign `Undefined`` or `NoMessage` to already set timestamp
    if timestamp == FirstMessageTimestampUndefined ||
        (timestamp == FirstMessageTimestampNoMessage &&
            c.FirstMessageTimestamp != FirstMessageTimestampUndefined) {
        return false
    }

    if c.FirstMessageTimestamp == FirstMessageTimestampUndefined ||
        c.FirstMessageTimestamp == FirstMessageTimestampNoMessage ||
        timestamp < c.FirstMessageTimestamp {
        c.FirstMessageTimestamp = timestamp
        return true
    }

    return false
}

// ChatMembershipUpdate represent an event on membership of the chat
type ChatMembershipUpdate struct {
    // Unique identifier for the event
    ID string `json:"id"`
    // Type indicates the kind of event
    Type protobuf.MembershipUpdateEvent_EventType `json:"type"`
    // Name represents the name in the event of changing name events
    Name string `json:"name,omitempty"`
    // Clock value of the event
    ClockValue uint64 `json:"clockValue"`
    // Signature of the event
    Signature string `json:"signature"`
    // Hex encoded public key of the creator of the event
    From string `json:"from"`
    // Target of the event for single-target events
    Member string `json:"member,omitempty"`
    // Target of the event for multi-target events
    Members []string `json:"members,omitempty"`
}

// ChatMember represents a member who participates in a group chat
type ChatMember struct {
    // ID is the hex encoded public key of the member
    ID string `json:"id"`
    // Admin indicates if the member is an admin of the group chat
    Admin bool `json:"admin"`
}

func (c ChatMember) PublicKey() (*ecdsa.PublicKey, error) {
    return common.HexToPubkey(c.ID)
}

func oneToOneChatID(publicKey *ecdsa.PublicKey) string {
    return types.EncodeHex(crypto.FromECDSAPub(publicKey))
}

func OneToOneFromPublicKey(pk *ecdsa.PublicKey, timesource common.TimeSource) *Chat {
    chatID := types.EncodeHex(crypto.FromECDSAPub(pk))
    newChat := CreateOneToOneChat(chatID[:8], pk, timesource)

    return newChat
}

func CreateOneToOneChat(name string, publicKey *ecdsa.PublicKey, timesource common.TimeSource) *Chat {
    timestamp := timesource.GetCurrentTime()
    return &Chat{
        ID:                       oneToOneChatID(publicKey),
        Name:                     name,
        Timestamp:                int64(timestamp),
        ReadMessagesAtClockValue: 0,
        Active:                   true,
        Joined:                   int64(timestamp),
        ChatType:                 ChatTypeOneToOne,
        Highlight:                true,
    }
}

func CreateCommunityChat(orgID, chatID string, orgChat *protobuf.CommunityChat, timesource common.TimeSource) *Chat {
    color := orgChat.Identity.Color
    if color == "" {
        color = chatColors[rand.Intn(len(chatColors))] // nolint: gosec
    }

    timestamp := timesource.GetCurrentTime()

    // Populate community _channel_ members to _chat_ members
    chatMembers := []ChatMember{}
    for pubKey := range orgChat.Members {
        chatMember := ChatMember{
            ID:    pubKey,
            Admin: false,
        }
        chatMembers = append(chatMembers, chatMember)
    }

    return &Chat{
        CommunityID:              orgID,
        CategoryID:               orgChat.CategoryId,
        HideIfPermissionsNotMet:  orgChat.HideIfPermissionsNotMet,
        Name:                     orgChat.Identity.DisplayName,
        Description:              orgChat.Identity.Description,
        Members:                  chatMembers,
        Active:                   true,
        Color:                    color,
        Emoji:                    orgChat.Identity.Emoji,
        ID:                       orgID + chatID,
        Timestamp:                int64(timestamp),
        Joined:                   int64(timestamp),
        ReadMessagesAtClockValue: 0,
        ChatType:                 ChatTypeCommunityChat,
        FirstMessageTimestamp:    orgChat.Identity.FirstMessageTimestamp,
        ViewersCanPostReactions:  orgChat.ViewersCanPostReactions,
    }
}

func (c *Chat) CommunityChannelID() string {
    return strings.TrimPrefix(c.ID, c.CommunityID)
}

func (c *Chat) DeepLink() string {
    if c.OneToOne() {
        return "status-app://p/" + c.ID
    }
    if c.PrivateGroupChat() {
        return "status-app://g/args?a2=" + c.ID
    }

    if c.CommunityChat() {
        communityChannelID := c.CommunityChannelID()
        pubkey, err := types.DecodeHex(c.CommunityID)
        if err != nil {
            return ""
        }

        serializedCommunityID, err := utils.SerializePublicKey(pubkey)

        if err != nil {
            return ""
        }

        return "status-app://cc/" + communityChannelID + "#" + serializedCommunityID
    }

    if c.Public() {
        return "status-app://" + c.ID
    }

    return ""
}

func CreateCommunityChats(org *communities.Community, timesource common.TimeSource) []*Chat {
    var chats []*Chat
    orgID := org.IDString()

    for chatID, chat := range org.Chats() {
        chats = append(chats, CreateCommunityChat(orgID, chatID, chat, timesource))
    }
    return chats
}

func CreatePublicChat(name string, timesource common.TimeSource) *Chat {
    timestamp := timesource.GetCurrentTime()
    return &Chat{
        ID:                       name,
        Name:                     name,
        Active:                   true,
        Timestamp:                int64(timestamp),
        Joined:                   int64(timestamp),
        ReadMessagesAtClockValue: 0,
        Color:                    chatColors[rand.Intn(len(chatColors))], // nolint: gosec
        ChatType:                 ChatTypePublic,
        Members:                  []ChatMember{},
    }
}

// Deprecated: buildProfileChatID shouldn't be used
// and is only left here in case profile chat feature is re-introduced.
func buildProfileChatID(publicKeyString string) string {
    return "@" + publicKeyString
}

// Deprecated: CreateProfileChat shouldn't be used
// and is only left here in case profile chat feature is re-introduced.
func CreateProfileChat(pubkey string, timesource common.TimeSource) *Chat {
    // Return nil to prevent usage of deprecated function
    if deprecation.ChatProfileDeprecated {
        return nil
    }

    id := buildProfileChatID(pubkey)
    return &Chat{
        ID:        id,
        Name:      id,
        Active:    true,
        Timestamp: int64(timesource.GetCurrentTime()),
        Joined:    int64(timesource.GetCurrentTime()),
        Color:     chatColors[rand.Intn(len(chatColors))], // nolint: gosec
        ChatType:  ChatTypeProfile,
        Profile:   pubkey,
    }
}

func CreateGroupChat(timesource common.TimeSource) Chat {
    timestamp := timesource.GetCurrentTime()
    synced := uint32(timestamp / 1000)

    return Chat{
        Active:                   true,
        Color:                    chatColors[rand.Intn(len(chatColors))], // nolint: gosec
        Timestamp:                int64(timestamp),
        ReadMessagesAtClockValue: 0,
        SyncedTo:                 synced,
        SyncedFrom:               synced,
        ChatType:                 ChatTypePrivateGroupChat,
        Highlight:                true,
    }
}

// Deprecated: CreateTimelineChat shouldn't be used
// and is only left here in case profile chat feature is re-introduced.
func CreateTimelineChat(timesource common.TimeSource) *Chat {
    // Return nil to prevent usage of deprecated function
    if deprecation.ChatTimelineDeprecated {
        return nil
    }

    return &Chat{
        ID:        timelineChatID,
        Name:      "#" + timelineChatID,
        Timestamp: int64(timesource.GetCurrentTime()),
        Active:    true,
        ChatType:  ChatTypeTimeline,
    }
}

func stringSliceToPublicKeys(slice []string) ([]*ecdsa.PublicKey, error) {
    result := make([]*ecdsa.PublicKey, len(slice))
    for idx, item := range slice {
        var err error
        result[idx], err = common.HexToPubkey(item)
        if err != nil {
            return nil, err
        }
    }
    return result, nil
}

func stringSliceContains(slice []string, item string) bool {
    for _, s := range slice {
        if s == item {
            return true
        }
    }
    return false
}

func GetChatContextFromChatType(chatType ChatType) ChatContext {
    switch chatType {
    case ChatTypeOneToOne, ChatTypePrivateGroupChat:
        return privateChat
    default:
        return publicChat
    }
}