This commit is contained in:
Jannis Mattheis
2018-11-06 19:38:31 +01:00
parent ffdb9792e2
commit fc491f4bde
16 changed files with 666 additions and 40 deletions

View File

@@ -11,6 +11,7 @@ import (
"github.com/gotify/location"
"github.com/gotify/server/auth"
"github.com/gotify/server/model"
"fmt"
)
// The MessageDatabase interface for encapsulating database access.
@@ -18,12 +19,15 @@ type MessageDatabase interface {
GetMessagesByApplicationSince(appID uint, limit int, since uint) []*model.Message
GetApplicationByID(id uint) *model.Application
GetMessagesByUserSince(userID uint, limit int, since uint) []*model.Message
GetUnreadMessagesByUserSince(userID uint, limit int, since uint) []*model.Message
DeleteMessageByID(id uint) error
GetMessageByID(id uint) *model.Message
DeleteMessagesByUser(userID uint) error
DeleteMessagesByApplication(applicationID uint) error
CreateMessage(message *model.Message) error
GetApplicationByToken(token string) *model.Application
GetApplicationsByUser(userID uint) []*model.Application
SetMessagesRead(ids []uint) error
}
// Notifier notifies when a new message was created.
@@ -42,6 +46,10 @@ type pagingParams struct {
Since uint `form:"since" binding:"min=0"`
}
type markAsReadParams struct {
Ids []uint `form:"id"`
}
// GetMessages returns all messages from a user.
func (a *MessageAPI) GetMessages(ctx *gin.Context) {
userID := auth.GetUserID(ctx)
@@ -52,6 +60,47 @@ func (a *MessageAPI) GetMessages(ctx *gin.Context) {
})
}
// GetUnreadMessages returns all unread messages from a user.
func (a *MessageAPI) GetUnreadMessages(ctx *gin.Context) {
userID := auth.GetUserID(ctx)
withPaging(ctx, func(params *pagingParams) {
// the +1 is used to check if there are more messages and will be removed on buildWithPaging
messages := a.DB.GetUnreadMessagesByUserSince(userID, params.Limit+1, params.Since)
ctx.JSON(200, buildWithPaging(ctx, params, messages))
})
}
// MarkMessagesAsRead marks messages as read.
func (a *MessageAPI) MarkMessagesAsRead(ctx *gin.Context) {
params := &markAsReadParams{}
if err := ctx.MustBindWith(params, binding.Query); err == nil {
ids := params.Ids
appIds := a.userAppIDs(auth.GetUserID(ctx))
for _, e := range ids {
message := a.DB.GetMessageByID(e)
if message == nil {
ctx.AbortWithError(404, errors.New(fmt.Sprintf("message with id %d does not exist", e)))
return
} else if _, ok := appIds[message.ApplicationID]; !ok {
ctx.AbortWithError(403, errors.New(fmt.Sprintf("you have no permission to change message with id %d", e)))
return
}
}
a.DB.SetMessagesRead(ids)
}
}
func (a *MessageAPI) userAppIDs(userID uint) map[uint]struct{} {
apps := a.DB.GetApplicationsByUser(userID)
appIds := make(map[uint]struct{})
for _, app := range apps {
appIds[app.ID] = struct{}{}
}
return appIds
}
func buildWithPaging(ctx *gin.Context, paging *pagingParams, messages []*model.Message) *model.PagedMessages {
next := ""
since := uint(0)

View File

@@ -59,7 +59,7 @@ func (s *MessageSuite) Test_ensureCorrectJsonRepresentation() {
Messages: []*model.Message{{ID: 55, ApplicationID: 2, Message: "hi", Title: "hi", Date: t, Priority: 4}},
}
test.JSONEquals(s.T(), actual, `{"paging": {"limit":5, "since": 122, "size": 5, "next": "http://example.com/message?limit=5&since=122"},
"messages": [{"id":55,"appid":2,"message":"hi","title":"hi","priority":4,"date":"2017-01-02T00:00:00Z"}]}`)
"messages": [{"id":55,"appid":2,"message":"hi","title":"hi","priority":4,"date":"2017-01-02T00:00:00Z", "read": false}]}`)
}
func (s *MessageSuite) Test_GetMessages() {
@@ -78,6 +78,23 @@ func (s *MessageSuite) Test_GetMessages() {
test.BodyEquals(s.T(), expected, s.recorder)
}
func (s *MessageSuite) Test_GetUnreadMessages() {
user := s.db.User(5)
first := user.App(1).NewMessage(1)
second := user.App(2).NewMessage(2)
user.App(3).NewMessage(3);
s.db.SetMessagesRead([]uint{3});
test.WithUser(s.ctx, 5)
s.a.GetUnreadMessages(s.ctx)
expected := &model.PagedMessages{
Paging: model.Paging{Limit: 100, Size: 2, Next: ""},
Messages: []*model.Message{&second, &first,},
}
test.BodyEquals(s.T(), expected, s.recorder)
}
func (s *MessageSuite) Test_GetMessages_WithLimit_ReturnsNext() {
user := s.db.User(5)
app1 := user.App(1)
@@ -134,6 +151,15 @@ func (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit() {
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *MessageSuite) Test_GetUnreadMessages_BadRequestOnInvalidLimit() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.withURL("http", "example.com", "/messages", "limit=555")
s.a.GetUnreadMessages(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit_Negative() {
s.db.User(5)
test.WithUser(s.ctx, 5)
@@ -143,6 +169,15 @@ func (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit_Negative() {
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *MessageSuite) Test_GetUnreadMessages_BadRequestOnInvalidLimit_Negative() {
s.db.User(5)
test.WithUser(s.ctx, 5)
s.withURL("http", "example.com", "/messages", "limit=-5")
s.a.GetUnreadMessages(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *MessageSuite) Test_GetMessagesWithToken_InvalidLimit_BadRequest() {
s.db.User(4).App(2).NewMessage(1)
@@ -437,6 +472,102 @@ func (s *MessageSuite) Test_CreateMessage_onFormData() {
assert.True(s.T(), s.notified)
}
func (s *MessageSuite) Test_MarkAsRead() {
user := s.db.User(2)
user.App(1).Message(1).Message(2)
user.App(2).Message(3).Message(4)
test.WithUser(s.ctx, 2)
s.withURL("http", "example.com", "/message/read", "id=3")
s.a.MarkMessagesAsRead(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
assert.False(s.T(), s.db.GetMessageByID(1).Read)
assert.False(s.T(), s.db.GetMessageByID(2).Read)
assert.True(s.T(), s.db.GetMessageByID(3).Read)
assert.False(s.T(), s.db.GetMessageByID(4).Read)
}
func (s *MessageSuite) Test_MarkAsRead_multiple() {
user := s.db.User(2)
user.App(1).Message(1).Message(2)
user.App(2).Message(3).Message(4)
test.WithUser(s.ctx, 2)
s.withURL("http", "example.com", "/message/read", "id=3&id=2")
s.a.MarkMessagesAsRead(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
assert.False(s.T(), s.db.GetMessageByID(1).Read)
assert.True(s.T(), s.db.GetMessageByID(2).Read)
assert.True(s.T(), s.db.GetMessageByID(3).Read)
assert.False(s.T(), s.db.GetMessageByID(4).Read)
}
func (s *MessageSuite) Test_MarkAsRead_wrongUser() {
one := s.db.User(2)
one.App(1).Message(1).Message(2)
one.App(2).Message(3).Message(4)
two := s.db.User(3)
two.App(3).Message(5)
test.WithUser(s.ctx, 2)
s.withURL("http", "example.com", "/message/read", "id=5")
s.a.MarkMessagesAsRead(s.ctx)
assert.Equal(s.T(), 403, s.recorder.Code)
assert.False(s.T(), s.db.GetMessageByID(5).Read)
}
func (s *MessageSuite) Test_MarkAsRead_wrongUser_onlyOneIdUnauthorized() {
one := s.db.User(2)
one.App(1).Message(1).Message(2)
one.App(2).Message(3).Message(4)
two := s.db.User(3)
two.App(3).Message(5)
test.WithUser(s.ctx, 2)
s.withURL("http", "example.com", "/message/read", "id=5&id=4")
s.a.MarkMessagesAsRead(s.ctx)
assert.Equal(s.T(), 403, s.recorder.Code)
assert.False(s.T(), s.db.GetMessageByID(5).Read)
assert.False(s.T(), s.db.GetMessageByID(4).Read)
}
func (s *MessageSuite) Test_MarkAsRead_unknownId() {
one := s.db.User(2)
one.App(1).Message(1).Message(2)
one.App(2).Message(3).Message(4)
test.WithUser(s.ctx, 2)
s.withURL("http", "example.com", "/message/read", "id=5")
s.a.MarkMessagesAsRead(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *MessageSuite) Test_MarkAsRead_unknownId_oneExistingId() {
one := s.db.User(2)
one.App(1).Message(1).Message(2)
one.App(2).Message(3).Message(4)
test.WithUser(s.ctx, 2)
s.withURL("http", "example.com", "/message/read", "id=5&id=4")
s.a.MarkMessagesAsRead(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
assert.False(s.T(), s.db.GetMessageByID(4).Read)
}
func (s *MessageSuite) withURL(scheme, host, path, query string) {
s.ctx.Request.URL = &url.URL{Path: path, RawQuery: query}
s.ctx.Set("location", &url.URL{Scheme: scheme, Host: host})

112
api/profile.out Normal file
View File

@@ -0,0 +1,112 @@
mode: atomic
github.com\gotify\server\api\internalutil.go:10.61,11.71 1 37
github.com\gotify\server\api\internalutil.go:11.71,13.3 1 33
github.com\gotify\server\api\internalutil.go:13.3,15.3 1 4
github.com\gotify\server\api\message.go:54.52,56.45 2 5
github.com\gotify\server\api\message.go:56.45,60.3 2 3
github.com\gotify\server\api\message.go:64.58,66.45 2 3
github.com\gotify\server\api\message.go:66.45,70.3 2 1
github.com\gotify\server\api\message.go:74.51,76.64 2 6
github.com\gotify\server\api\message.go:76.64,79.25 3 6
github.com\gotify\server\api\message.go:91.3,91.28 1 2
github.com\gotify\server\api\message.go:79.25,82.22 2 7
github.com\gotify\server\api\message.go:82.22,85.5 2 2
github.com\gotify\server\api\message.go:85.5,85.58 1 5
github.com\gotify\server\api\message.go:85.58,88.5 2 2
github.com\gotify\server\api\message.go:95.64,98.27 3 6
github.com\gotify\server\api\message.go:101.2,101.15 1 6
github.com\gotify\server\api\message.go:98.27,100.3 1 12
github.com\gotify\server\api\message.go:104.110,108.34 4 7
github.com\gotify\server\api\message.go:119.2,122.3 1 7
github.com\gotify\server\api\message.go:108.34,118.3 9 4
github.com\gotify\server\api\message.go:125.71,127.64 2 13
github.com\gotify\server\api\message.go:127.64,129.3 1 8
github.com\gotify\server\api\message.go:133.67,134.34 1 5
github.com\gotify\server\api\message.go:134.34,135.46 1 5
github.com\gotify\server\api\message.go:135.46,136.91 1 4
github.com\gotify\server\api\message.go:136.91,140.5 2 3
github.com\gotify\server\api\message.go:140.5,142.5 1 1
github.com\gotify\server\api\message.go:148.55,151.2 2 1
github.com\gotify\server\api\message.go:154.69,155.34 1 3
github.com\gotify\server\api\message.go:155.34,156.114 1 3
github.com\gotify\server\api\message.go:156.114,158.4 1 1
github.com\gotify\server\api\message.go:158.4,160.4 1 2
github.com\gotify\server\api\message.go:165.54,166.34 1 4
github.com\gotify\server\api\message.go:166.34,167.125 1 3
github.com\gotify\server\api\message.go:167.125,169.4 1 1
github.com\gotify\server\api\message.go:169.4,171.4 1 2
github.com\gotify\server\api\message.go:176.54,178.43 2 7
github.com\gotify\server\api\message.go:178.43,184.3 5 4
github.com\gotify\server\api\token.go:43.56,45.39 2 5
github.com\gotify\server\api\token.go:45.39,50.3 4 4
github.com\gotify\server\api\token.go:54.51,56.42 2 4
github.com\gotify\server\api\token.go:56.42,61.3 4 3
github.com\gotify\server\api\token.go:65.54,68.27 3 2
github.com\gotify\server\api\token.go:71.2,71.21 1 2
github.com\gotify\server\api\token.go:68.27,70.3 1 4
github.com\gotify\server\api\token.go:75.49,79.2 3 1
github.com\gotify\server\api\token.go:82.56,83.34 1 4
github.com\gotify\server\api\token.go:83.34,84.90 1 4
github.com\gotify\server\api\token.go:84.90,86.23 2 2
github.com\gotify\server\api\token.go:86.23,88.5 1 1
github.com\gotify\server\api\token.go:89.4,91.4 1 2
github.com\gotify\server\api\token.go:96.51,97.34 1 3
github.com\gotify\server\api\token.go:97.34,98.94 1 3
github.com\gotify\server\api\token.go:98.94,101.4 2 1
github.com\gotify\server\api\token.go:101.4,103.4 1 2
github.com\gotify\server\api\token.go:108.61,109.34 1 8
github.com\gotify\server\api\token.go:109.34,110.90 1 8
github.com\gotify\server\api\token.go:110.90,112.34 2 7
github.com\gotify\server\api\token.go:119.4,122.31 4 5
github.com\gotify\server\api\token.go:127.4,130.39 3 4
github.com\gotify\server\api\token.go:134.4,135.18 2 4
github.com\gotify\server\api\token.go:140.4,140.23 1 3
github.com\gotify\server\api\token.go:144.4,146.44 3 3
github.com\gotify\server\api\token.go:112.34,115.5 2 1
github.com\gotify\server\api\token.go:115.5,115.25 1 6
github.com\gotify\server\api\token.go:115.25,118.5 2 1
github.com\gotify\server\api\token.go:122.31,125.5 2 1
github.com\gotify\server\api\token.go:130.39,132.5 1 1
github.com\gotify\server\api\token.go:135.18,138.5 2 1
github.com\gotify\server\api\token.go:140.23,142.5 1 2
github.com\gotify\server\api\token.go:147.4,149.4 1 1
github.com\gotify\server\api\token.go:153.30,154.49 1 5
github.com\gotify\server\api\token.go:157.2,157.13 1 1
github.com\gotify\server\api\token.go:154.49,156.3 1 4
github.com\gotify\server\api\token.go:160.83,163.21 2 11
github.com\gotify\server\api\token.go:168.2,169.12 2 11
github.com\gotify\server\api\token.go:163.21,165.3 1 7
github.com\gotify\server\api\token.go:165.3,167.3 1 4
github.com\gotify\server\api\token.go:172.57,174.2 1 5
github.com\gotify\server\api\token.go:176.52,178.2 1 4
github.com\gotify\server\api\token.go:180.104,181.6 1 7
github.com\gotify\server\api\token.go:181.6,183.26 2 9
github.com\gotify\server\api\token.go:183.26,185.4 1 7
github.com\gotify\server\api\user.go:30.46,34.29 3 1
github.com\gotify\server\api\user.go:38.2,38.21 1 1
github.com\gotify\server\api\user.go:34.29,36.3 1 2
github.com\gotify\server\api\user.go:42.52,45.2 2 1
github.com\gotify\server\api\user.go:48.48,50.40 2 4
github.com\gotify\server\api\user.go:50.40,52.47 2 2
github.com\gotify\server\api\user.go:52.47,55.4 2 1
github.com\gotify\server\api\user.go:55.4,57.4 1 1
github.com\gotify\server\api\user.go:62.49,63.34 1 3
github.com\gotify\server\api\user.go:63.34,64.54 1 2
github.com\gotify\server\api\user.go:64.54,66.4 1 1
github.com\gotify\server\api\user.go:66.4,68.4 1 1
github.com\gotify\server\api\user.go:73.52,74.34 1 3
github.com\gotify\server\api\user.go:74.34,75.48 1 2
github.com\gotify\server\api\user.go:75.48,78.4 2 1
github.com\gotify\server\api\user.go:78.4,80.4 1 1
github.com\gotify\server\api\user.go:85.52,87.38 2 2
github.com\gotify\server\api\user.go:87.38,91.3 3 1
github.com\gotify\server\api\user.go:95.52,96.34 1 4
github.com\gotify\server\api\user.go:96.34,98.41 2 3
github.com\gotify\server\api\user.go:98.41,99.55 1 3
github.com\gotify\server\api\user.go:99.55,104.5 4 2
github.com\gotify\server\api\user.go:104.5,106.5 1 1
github.com\gotify\server\api\user.go:111.91,116.25 2 4
github.com\gotify\server\api\user.go:121.2,121.13 1 4
github.com\gotify\server\api\user.go:116.25,118.3 1 3
github.com\gotify\server\api\user.go:118.3,120.3 1 1
github.com\gotify\server\api\user.go:124.59,130.2 1 7

2
app.go
View File

@@ -18,7 +18,7 @@ import (
var (
// Version the version of Gotify.
Version = "unknown"
Version = "1.1.7"
// Commit the git commit hash of this version.
Commit = "unknown"
// BuildDate the date on which this binary was build.

View File

@@ -40,6 +40,24 @@ func (d *GormDatabase) GetMessagesByUserSince(userID uint, limit int, since uint
return messages
}
// GetUnreadMessagesByUserSince returns limited messages from a user.
// If since is 0 it will be ignored.
func (d *GormDatabase) GetUnreadMessagesByUserSince(userID uint, limit int, since uint) []*model.Message {
var messages []*model.Message
db := d.DB.Joins("JOIN applications ON applications.user_id = ?", userID).
Where("messages.application_id = applications.id").Where("messages.read = ?", false).Order("id desc").Limit(limit)
if since != 0 {
db = db.Where("messages.id < ?", since)
}
db.Find(&messages)
return messages
}
// SetMessagesRead sets the read flag on messages to true
func (d *GormDatabase) SetMessagesRead(ids []uint) error {
return d.DB.Model(&model.Message{}).Where("id in (?)", ids).Update("read", true).Error
}
// GetMessagesByApplication returns all messages from an application.
func (d *GormDatabase) GetMessagesByApplication(tokenID uint) []*model.Message {
var messages []*model.Message

View File

@@ -6,6 +6,7 @@ import (
"github.com/gotify/server/model"
"github.com/stretchr/testify/assert"
"fmt"
)
func (s *DatabaseSuite) TestMessage() {
@@ -34,6 +35,16 @@ func (s *DatabaseSuite) TestMessage() {
assert.Len(s.T(), msgs, 1)
assertEquals(s.T(), msgs[0], backupdone)
msgs = s.db.GetUnreadMessagesByUserSince(user.ID, 100, 0)
assert.Len(s.T(), msgs, 1)
assertEquals(s.T(), msgs[0], backupdone)
s.db.SetMessagesRead([]uint{backupdone.ID})
backupdone.Read = true
msgs = s.db.GetUnreadMessagesByUserSince(user.ID, 100, 0)
assert.Len(s.T(), msgs, 0)
msgs = s.db.GetMessagesByApplication(backupServer.ID)
assert.Len(s.T(), msgs, 1)
assertEquals(s.T(), msgs[0], backupdone)
@@ -51,6 +62,10 @@ func (s *DatabaseSuite) TestMessage() {
assertEquals(s.T(), msgs[0], logindone)
assertEquals(s.T(), msgs[1], backupdone)
msgs = s.db.GetUnreadMessagesByUserSince(user.ID, 100, 0)
assert.Len(s.T(), msgs, 1)
assertEquals(s.T(), msgs[0], logindone)
msgs = s.db.GetMessagesByApplication(backupServer.ID)
assert.Len(s.T(), msgs, 1)
assertEquals(s.T(), msgs[0], backupdone)
@@ -74,6 +89,11 @@ func (s *DatabaseSuite) TestMessage() {
assertEquals(s.T(), msgs[1], logindone)
assertEquals(s.T(), msgs[2], backupdone)
msgs = s.db.GetUnreadMessagesByUserSince(user.ID, 100, 0)
assert.Len(s.T(), msgs, 2)
assertEquals(s.T(), msgs[0], loginfailed)
assertEquals(s.T(), msgs[1], logindone)
backupfailed := &model.Message{ApplicationID: backupServer.ID, Message: "backup failed", Title: "backup", Priority: 1, Date: time.Now()}
s.db.CreateMessage(backupfailed)
assert.NotEqual(s.T(), 0, backupfailed.ID)
@@ -85,6 +105,15 @@ func (s *DatabaseSuite) TestMessage() {
assertEquals(s.T(), msgs[2], logindone)
assertEquals(s.T(), msgs[3], backupdone)
s.db.SetMessagesRead([]uint{loginfailed.ID, logindone.ID})
loginfailed.Read = true
logindone.Read = true
msgs = s.db.GetUnreadMessagesByUserSince(user.ID, 100, 0)
fmt.Print(msgs)
assert.Len(s.T(), msgs, 1)
assertEquals(s.T(), msgs[0], backupfailed)
msgs = s.db.GetMessagesByApplication(loginServer.ID)
assert.Len(s.T(), msgs, 2)
assertEquals(s.T(), msgs[0], loginfailed)
@@ -140,6 +169,10 @@ func (s *DatabaseSuite) TestGetMessagesSince() {
assert.Len(s.T(), actual, 50)
hasIDInclusiveBetween(s.T(), actual, 1000, 951, 1)
actual = s.db.GetUnreadMessagesByUserSince(user.ID, 50, 0)
assert.Len(s.T(), actual, 50)
hasIDInclusiveBetween(s.T(), actual, 1000, 951, 1)
actual = s.db.GetMessagesByUserSince(user.ID, 50, 951)
assert.Len(s.T(), actual, 50)
hasIDInclusiveBetween(s.T(), actual, 950, 901, 1)

View File

@@ -753,6 +753,119 @@
}
}
},
"/message/read": {
"post": {
"security": [
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"message"
],
"summary": "Marks messages as read.",
"operationId": "markMessagesAsRead",
"parameters": [
{
"type": "array",
"items": {
"type": "integer"
},
"description": "the message ids",
"name": "id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Ok"
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/message/unread": {
"get": {
"security": [
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"message"
],
"summary": "Return all unread messages.",
"operationId": "getUnreadMessages",
"parameters": [
{
"maximum": 200,
"minimum": 1,
"type": "integer",
"default": 100,
"description": "the maximal amount of messages to return",
"name": "limit",
"in": "query"
},
{
"minimum": 0,
"type": "integer",
"description": "return all messages with an ID less than this value",
"name": "since",
"in": "query"
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/PagedMessages"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/message/{id}": {
"delete": {
"security": [
@@ -1256,7 +1369,8 @@
"appid",
"message",
"title",
"date"
"date",
"read"
],
"properties": {
"appid": {
@@ -1296,6 +1410,13 @@
"x-go-name": "Priority",
"example": 2
},
"read": {
"description": "If the message was already read.",
"type": "boolean",
"x-go-name": "Read",
"readOnly": true,
"example": true
},
"title": {
"description": "The title of the message.",
"type": "string",

View File

@@ -40,4 +40,10 @@ type Message struct {
// required: true
// example: 2018-02-27T19:36:10.5045044+01:00
Date time.Time `json:"date"`
// If the message was already read.
//
// read only: true
// required: true
// example: true
Read bool `json:"read"`
}

View File

@@ -462,6 +462,83 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
// $ref: "#/definitions/Error"
message.GET("", messageHandler.GetMessages)
// swagger:operation GET /message/unread message getUnreadMessages
//
// Return all unread messages.
//
// ---
// produces:
// - application/json
// security:
// - clientTokenHeader: []
// - clientTokenQuery: []
// - basicAuth: []
// parameters:
// - name: limit
// in: query
// description: the maximal amount of messages to return
// required: false
// maximum: 200
// minimum: 1
// default: 100
// type: integer
// - name: since
// in: query
// description: return all messages with an ID less than this value
// minimum: 0
// required: false
// type: integer
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/PagedMessages"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
message.GET("/unread", messageHandler.GetUnreadMessages)
// swagger:operation POST /message/read message markMessagesAsRead
//
// Marks messages as read.
//
// ---
// produces:
// - application/json
// security:
// - clientTokenHeader: []
// - clientTokenQuery: []
// - basicAuth: []
// parameters:
// - name: id
// in: query
// description: the message ids
// required: true
// type: array
// items:
// type: integer
// responses:
// 200:
// description: Ok
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
message.POST("/read", messageHandler.MarkMessagesAsRead)
// swagger:operation DELETE /message message deleteMessages
//
// Delete all messages.

7
ui/load.js Normal file
View File

@@ -0,0 +1,7 @@
let id = 0;
module.exports = function(requestId) {
return {
message: requestId,
title: ""+(id++)
};
};

17
ui/package-lock.json generated
View File

@@ -318,6 +318,15 @@
"@types/react": "*"
}
},
"@types/react-visibility-sensor": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@types/react-visibility-sensor/-/react-visibility-sensor-3.11.0.tgz",
"integrity": "sha512-a5T4bW3tIUkZM8L9CA92nRJzMARRw+aqSs7r2dv8K1NZA1Pot7mHhAUgaKLlrCPZeZ7LynQXIQJhbMiQsZdkGw==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/rimraf": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@types/rimraf/-/rimraf-2.0.2.tgz",
@@ -9814,6 +9823,14 @@
}
}
},
"react-visibility-sensor": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-visibility-sensor/-/react-visibility-sensor-5.0.1.tgz",
"integrity": "sha512-EVrSpvFN8dD4svTXICejydCd4F5ufTDj4ARRzKYBScaJ/pPsViuYRbeI02SaSySnL2v4/ONt0XveeUhkZbk0gg==",
"requires": {
"prop-types": "^15.6.2"
}
},
"read-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz",

View File

@@ -19,6 +19,7 @@
"react-router-dom": "^4.3.1",
"react-scripts-ts": "2.17.0",
"react-timeago": "^4.1.9",
"react-visibility-sensor": "^5.0.1",
"typeface-roboto": "0.0.54",
"typeface-roboto-mono": "0.0.54"
},
@@ -43,6 +44,7 @@
"@types/react-dom": "^16.0.7",
"@types/react-infinite": "0.0.33",
"@types/react-router-dom": "^4.3.0",
"@types/react-visibility-sensor": "^3.11.0",
"@types/rimraf": "^2.0.2",
"get-port": "^4.0.0",
"prettier": "^1.14.2",

View File

@@ -1,12 +1,12 @@
import IconButton from '@material-ui/core/IconButton';
import {withStyles, WithStyles} from '@material-ui/core/styles';
import {StyleRules, withStyles, WithStyles} from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import Delete from '@material-ui/icons/Delete';
import React from 'react';
import TimeAgo from 'react-timeago';
import Container from '../common/Container';
const styles = () => ({
const styles = (): StyleRules => ({
header: {
display: 'flex',
},
@@ -31,19 +31,12 @@ const styles = () => ({
},
});
type Style = WithStyles<
| 'header'
| 'headerTitle'
| 'trash'
| 'wrapperPadding'
| 'messageContentWrapper'
| 'image'
| 'imageWrapper'
>;
type Style = WithStyles<typeof styles>;
interface IProps {
title: string;
image?: string;
read: boolean;
date: string;
content: string;
fDelete: VoidFunction;
@@ -57,10 +50,20 @@ class Message extends React.PureComponent<IProps & Style> {
this.props.height(this.node ? this.node.getBoundingClientRect().height : 0);
public render(): React.ReactNode {
const {fDelete, classes, title, date, content, image} = this.props;
const {fDelete, classes, title, date, content, image, read} = this.props;
const containerStyle: React.CSSProperties = {display: 'flex', transition: 'all 3s linear'};
if (read) {
containerStyle.borderTop = '0px solid black';
containerStyle.padding = '16px';
} else {
containerStyle.borderTop = '5px solid orange';
containerStyle.padding = '11px 16px 16px 16px';
}
return (
<div className={`${classes.wrapperPadding} message`} ref={(ref) => (this.node = ref)}>
<Container style={{display: 'flex'}}>
<Container style={containerStyle}>
<div className={classes.imageWrapper}>
<img
src={image}

View File

@@ -6,6 +6,7 @@ import {RouteComponentProps} from 'react-router';
import DefaultPage from '../common/DefaultPage';
import Message from './Message';
import {observer} from 'mobx-react';
import VisibilitySensor from 'react-visibility-sensor';
import {inject, Stores} from '../inject';
import {observable} from 'mobx';
import ReactInfinite from 'react-infinite';
@@ -103,20 +104,28 @@ class Messages extends Component<IProps & Stores<'messagesStore' | 'appStore'>,
this.props.messagesStore.removeSingle(message);
private renderMessage = (message: IMessage) => {
const onVisibilityChange = (visible: boolean) => {
if (visible && !message.read) {
this.props.messagesStore.markAsRead(message);
}
};
return (
<Message
key={message.id}
height={(height) => {
if (!this.heights[message.id]) {
this.heights[message.id] = height;
}
}}
fDelete={this.deleteMessage(message)}
title={message.title}
date={message.date}
content={message.message}
image={message.image}
/>
// @ts-ignore
<VisibilitySensor key={message.id} onChange={onVisibilityChange}>
<Message
height={(height) => {
if (!this.heights[message.id]) {
this.heights[message.id] = height;
}
}}
fDelete={this.deleteMessage(message)}
title={message.title}
date={message.date}
content={message.message}
read={message.read}
image={message.image}
/>
</VisibilitySensor>
);
};

View File

@@ -1,8 +1,8 @@
import {BaseStore} from '../common/BaseStore';
import {action, IObservableArray, observable, reaction} from 'mobx';
import {action, IObservableArray, observable, reaction, transaction} from 'mobx';
import axios, {AxiosResponse} from 'axios';
import * as config from '../config';
import {createTransformer} from 'mobx-utils';
import {chunkProcessor, createTransformer} from 'mobx-utils';
import {SnackReporter} from '../snack/SnackManager';
const AllMessages = -1;
@@ -18,6 +18,12 @@ export class MessagesStore {
@observable
private state: Record<number, MessagesState> = {};
@observable
private readMessageQueue: number[] = [];
@observable
private newMessageQueue: IMessage[] = [];
private loading = false;
public constructor(
@@ -25,8 +31,14 @@ export class MessagesStore {
private readonly snack: SnackReporter
) {
reaction(() => appStore.getItems(), this.createEmptyStatesForApps);
chunkProcessor(this.readMessageQueue, this.markMessagesAsReadRemote, 1000);
chunkProcessor(this.newMessageQueue, (messages) => messages.map(this.showNewMessage), 200);
}
private markMessagesAsReadRemote = (ids: number[]) => {
axios.post(config.get('url') + 'message/read?' + ids.map((id) => `id=${id}`).join('&'));
};
private stateOf = (appId: number, create = true) => {
if (this.state[appId] || !create) {
return this.state[appId] || this.emptyState();
@@ -47,17 +59,47 @@ export class MessagesStore {
const pagedResult = await this.fetchMessages(appId, state.nextSince).then(
(resp) => resp.data
);
state.messages.replace([...state.messages, ...pagedResult.messages]);
state.nextSince = pagedResult.paging.since || 0;
state.hasMore = 'next' in pagedResult.paging;
state.loaded = true;
this.loading = false;
transaction(() => {
state.loaded = true;
state.nextSince = pagedResult.paging.since || 0;
state.hasMore = 'next' in pagedResult.paging;
state.messages.replace([...state.messages, ...pagedResult.messages]);
this.loading = false;
});
return Promise.resolve();
};
@action
public markAsRead = (message: IMessage) => {
console.log('MARK AS READ', this.stateOf(-1).loaded);
if (this.exists(AllMessages)) {
console.log('ALl messages');
this.markAsReadWithMessages(this.state[AllMessages].messages, message);
}
if (this.exists(message.appid)) {
this.markAsReadWithMessages(this.state[message.appid].messages, message);
}
this.readMessageQueue.push(message.id);
};
private markAsReadWithMessages = (messages: IMessage[], toUpdate: IMessage) => {
const foundMessage = messages.find((message) => toUpdate.id === message.id);
console.log('UPDATE MESSAGE', foundMessage);
if (foundMessage) {
foundMessage.read = true;
}
};
@action
public publishSingleMessage = (message: IMessage) => {
this.newMessageQueue.push(message);
};
@action
public showNewMessage = (message: IMessage) => {
if (this.exists(AllMessages)) {
this.stateOf(AllMessages).messages.unshift(message);
}
@@ -101,15 +143,13 @@ export class MessagesStore {
public exists = (id: number) => this.stateOf(id).loaded;
private removeFromList(messages: IMessage[], messageToDelete: IMessage): false | number {
private removeFromList(messages: IMessage[], messageToDelete: IMessage) {
if (messages) {
const index = messages.findIndex((message) => message.id === messageToDelete.id);
if (index !== -1) {
messages.splice(index, 1);
return index;
}
}
return false;
}
private clear = (appId: number) => (this.state[appId] = this.emptyState());

View File

@@ -20,6 +20,7 @@ interface IMessage {
priority: number;
date: string;
image?: string;
read: boolean;
}
interface IPagedMessages {