Add typing notifications to /sync responses - fixes #635 (#718)

This PR adds a new consumer for typing notifications in syncapi. It also brings changes to syncserver.go and some related files so EDUs can better fit in /sync responses.

Fixes #635.
Fixes #574.
This commit is contained in:
Alex Chen 2019-07-12 22:59:53 +08:00 committed by GitHub
parent f8463063ac
commit 29841bed6b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 712 additions and 252 deletions

View file

@ -12,14 +12,17 @@
package api
import "time"
// OutputTypingEvent is an entry in typing server output kafka log.
// This contains the event with extra fields used to create 'm.typing' event
// in clientapi & federation.
type OutputTypingEvent struct {
// The Event for the typing edu event.
Event TypingEvent `json:"event"`
// Users typing in the room when the event was generated.
TypingUsers []string `json:"typing_users"`
// ExpireTime is the interval after which the user should no longer be
// considered typing. Only available if Event.Typing is true.
ExpireTime *time.Time
}
// TypingEvent represents a matrix edu event of type 'm.typing'.

View file

@ -22,25 +22,66 @@ const defaultTypingTimeout = 10 * time.Second
// userSet is a map of user IDs to a timer, timer fires at expiry.
type userSet map[string]*time.Timer
// TimeoutCallbackFn is a function called right after the removal of a user
// from the typing user list due to timeout.
// latestSyncPosition is the typing sync position after the removal.
type TimeoutCallbackFn func(userID, roomID string, latestSyncPosition int64)
type roomData struct {
syncPosition int64
userSet userSet
}
// TypingCache maintains a list of users typing in each room.
type TypingCache struct {
sync.RWMutex
data map[string]userSet
latestSyncPosition int64
data map[string]*roomData
timeoutCallback TimeoutCallbackFn
}
// Create a roomData with its sync position set to the latest sync position.
// Must only be called after locking the cache.
func (t *TypingCache) newRoomData() *roomData {
return &roomData{
syncPosition: t.latestSyncPosition,
userSet: make(userSet),
}
}
// NewTypingCache returns a new TypingCache initialised for use.
func NewTypingCache() *TypingCache {
return &TypingCache{data: make(map[string]userSet)}
return &TypingCache{data: make(map[string]*roomData)}
}
// SetTimeoutCallback sets a callback function that is called right after
// a user is removed from the typing user list due to timeout.
func (t *TypingCache) SetTimeoutCallback(fn TimeoutCallbackFn) {
t.timeoutCallback = fn
}
// GetTypingUsers returns the list of users typing in a room.
func (t *TypingCache) GetTypingUsers(roomID string) (users []string) {
func (t *TypingCache) GetTypingUsers(roomID string) []string {
users, _ := t.GetTypingUsersIfUpdatedAfter(roomID, 0)
// 0 should work above because the first position used will be 1.
return users
}
// GetTypingUsersIfUpdatedAfter returns all users typing in this room with
// updated == true if the typing sync position of the room is after the given
// position. Otherwise, returns an empty slice with updated == false.
func (t *TypingCache) GetTypingUsersIfUpdatedAfter(
roomID string, position int64,
) (users []string, updated bool) {
t.RLock()
usersMap, ok := t.data[roomID]
t.RUnlock()
if ok {
users = make([]string, 0, len(usersMap))
for userID := range usersMap {
defer t.RUnlock()
roomData, ok := t.data[roomID]
if ok && roomData.syncPosition > position {
updated = true
userSet := roomData.userSet
users = make([]string, 0, len(userSet))
for userID := range userSet {
users = append(users, userID)
}
}
@ -51,25 +92,41 @@ func (t *TypingCache) GetTypingUsers(roomID string) (users []string) {
// AddTypingUser sets an user as typing in a room.
// expire is the time when the user typing should time out.
// if expire is nil, defaultTypingTimeout is assumed.
func (t *TypingCache) AddTypingUser(userID, roomID string, expire *time.Time) {
// Returns the latest sync position for typing after update.
func (t *TypingCache) AddTypingUser(
userID, roomID string, expire *time.Time,
) int64 {
expireTime := getExpireTime(expire)
if until := time.Until(expireTime); until > 0 {
timer := time.AfterFunc(until, t.timeoutCallback(userID, roomID))
t.addUser(userID, roomID, timer)
timer := time.AfterFunc(until, func() {
latestSyncPosition := t.RemoveUser(userID, roomID)
if t.timeoutCallback != nil {
t.timeoutCallback(userID, roomID, latestSyncPosition)
}
})
return t.addUser(userID, roomID, timer)
}
return t.GetLatestSyncPosition()
}
// addUser with mutex lock & replace the previous timer.
func (t *TypingCache) addUser(userID, roomID string, expiryTimer *time.Timer) {
// Returns the latest typing sync position after update.
func (t *TypingCache) addUser(
userID, roomID string, expiryTimer *time.Timer,
) int64 {
t.Lock()
defer t.Unlock()
t.latestSyncPosition++
if t.data[roomID] == nil {
t.data[roomID] = make(userSet)
t.data[roomID] = t.newRoomData()
} else {
t.data[roomID].syncPosition = t.latestSyncPosition
}
// Stop the timer to cancel the call to timeoutCallback
if timer, ok := t.data[roomID][userID]; ok {
if timer, ok := t.data[roomID].userSet[userID]; ok {
// It may happen that at this stage timer fires but now we have a lock on t.
// Hence the execution of timeoutCallback will happen after we unlock.
// So we may lose a typing state, though this event is highly unlikely.
@ -78,26 +135,40 @@ func (t *TypingCache) addUser(userID, roomID string, expiryTimer *time.Timer) {
timer.Stop()
}
t.data[roomID][userID] = expiryTimer
}
t.data[roomID].userSet[userID] = expiryTimer
// Returns a function which is called after timeout happens.
// This removes the user.
func (t *TypingCache) timeoutCallback(userID, roomID string) func() {
return func() {
t.RemoveUser(userID, roomID)
}
return t.latestSyncPosition
}
// RemoveUser with mutex lock & stop the timer.
func (t *TypingCache) RemoveUser(userID, roomID string) {
// Returns the latest sync position for typing after update.
func (t *TypingCache) RemoveUser(userID, roomID string) int64 {
t.Lock()
defer t.Unlock()
if timer, ok := t.data[roomID][userID]; ok {
timer.Stop()
delete(t.data[roomID], userID)
roomData, ok := t.data[roomID]
if !ok {
return t.latestSyncPosition
}
timer, ok := roomData.userSet[userID]
if !ok {
return t.latestSyncPosition
}
timer.Stop()
delete(roomData.userSet, userID)
t.latestSyncPosition++
t.data[roomID].syncPosition = t.latestSyncPosition
return t.latestSyncPosition
}
func (t *TypingCache) GetLatestSyncPosition() int64 {
t.Lock()
defer t.Unlock()
return t.latestSyncPosition
}
func getExpireTime(expire *time.Time) time.Time {

View file

@ -57,15 +57,21 @@ func (t *TypingServerInputAPI) InputTypingEvent(
}
func (t *TypingServerInputAPI) sendEvent(ite *api.InputTypingEvent) error {
userIDs := t.Cache.GetTypingUsers(ite.RoomID)
ev := &api.TypingEvent{
Type: gomatrixserverlib.MTyping,
RoomID: ite.RoomID,
UserID: ite.UserID,
Typing: ite.Typing,
}
ote := &api.OutputTypingEvent{
Event: *ev,
TypingUsers: userIDs,
Event: *ev,
}
if ev.Typing {
expireTime := ite.OriginServerTS.Time().Add(
time.Duration(ite.Timeout) * time.Millisecond,
)
ote.ExpireTime = &expireTime
}
eventJSON, err := json.Marshal(ote)