mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-02-01 22:47:40 +00:00
feat: format user mentions with display names in email notifications (#1930)
Email notifications now display user mentions with inline avatar images for improved visual recognition and easier identification. Mentions gracefully fall back to display names if avatars are unavailable.
This commit is contained in:
@@ -32,6 +32,8 @@ import (
|
||||
type Provider interface {
|
||||
// GetAvatar is the method used to get an actual avatar for a user
|
||||
GetAvatar(user *user.User, size int64) (avatar []byte, mimeType string, err error)
|
||||
// AsDataURI returns a base64-encoded string representation of the avatar suitable for inline use
|
||||
AsDataURI(user *user.User, size int64) (inlineData string, err error)
|
||||
// FlushCache removes cached avatar data for the user
|
||||
FlushCache(u *user.User) error
|
||||
}
|
||||
@@ -53,3 +55,28 @@ func FlushAllCaches(u *user.User) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetProvider returns the appropriate avatar provider for a user
|
||||
func GetProvider(u *user.User) Provider {
|
||||
provider := u.AvatarProvider
|
||||
if provider == "" {
|
||||
provider = "empty"
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "gravatar":
|
||||
return &gravatar.Provider{}
|
||||
case "initials":
|
||||
return &initials.Provider{}
|
||||
case "upload":
|
||||
return &upload.Provider{}
|
||||
case "marble":
|
||||
return &marble.Provider{}
|
||||
case "ldap":
|
||||
return &ldap.Provider{}
|
||||
case "openid":
|
||||
return &openid.Provider{}
|
||||
default:
|
||||
return &empty.Provider{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
|
||||
package empty
|
||||
|
||||
import "code.vikunja.io/api/pkg/user"
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
)
|
||||
|
||||
// Provider represents the empty avatar provider
|
||||
type Provider struct {
|
||||
@@ -46,3 +51,12 @@ const defaultAvatar string = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
func (p *Provider) GetAvatar(_ *user.User, _ int64) (avatar []byte, mimeType string, err error) {
|
||||
return []byte(defaultAvatar), "image/svg+xml", nil
|
||||
}
|
||||
|
||||
// AsDataURI returns a data URI for the default SVG avatar
|
||||
func (p *Provider) AsDataURI(_ *user.User, _ int64) (string, error) {
|
||||
// Encode the SVG as base64 and create a data URI
|
||||
base64Data := base64.StdEncoding.EncodeToString([]byte(defaultAvatar))
|
||||
dataURI := fmt.Sprintf("data:image/svg+xml;base64,%s", base64Data)
|
||||
|
||||
return dataURI, nil
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ package gravatar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -123,6 +125,20 @@ func (g *Provider) GetAvatar(user *user.User, size int64) ([]byte, string, error
|
||||
return av.Content, av.MimeType, nil
|
||||
}
|
||||
|
||||
// AsDataURI returns a base64 encoded data URI for the gravatar
|
||||
func (g *Provider) AsDataURI(user *user.User, size int64) (string, error) {
|
||||
avatarData, mimeType, err := g.GetAvatar(user, size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Encode the avatar data as base64 and create a data URI
|
||||
base64Data := base64.StdEncoding.EncodeToString(avatarData)
|
||||
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
|
||||
|
||||
return dataURI, nil
|
||||
}
|
||||
|
||||
func (g *Provider) avatarExpired(av avatar) bool {
|
||||
return time.Since(av.LoadedAt) > time.Duration(config.AvatarGravaterExpiration.GetInt64())*time.Second
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package initials
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html"
|
||||
"strconv"
|
||||
@@ -81,3 +82,17 @@ func (p *Provider) GetAvatar(u *user.User, size int64) (avatar []byte, mimeType
|
||||
|
||||
return []byte(svg), "image/svg+xml", nil
|
||||
}
|
||||
|
||||
// AsDataURI returns a data URI for the SVG avatar
|
||||
func (p *Provider) AsDataURI(u *user.User, size int64) (string, error) {
|
||||
avatarData, mimeType, err := p.GetAvatar(u, size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Encode the SVG as base64 and create a data URI
|
||||
base64Data := base64.StdEncoding.EncodeToString(avatarData)
|
||||
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
|
||||
|
||||
return dataURI, nil
|
||||
}
|
||||
|
||||
77
pkg/modules/avatar/inline_profile_picture_test.go
Normal file
77
pkg/modules/avatar/inline_profile_picture_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package avatar
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/modules/avatar/empty"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/initials"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/marble"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
)
|
||||
|
||||
func TestAsDataURI(t *testing.T) {
|
||||
testUser := &user.User{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
Name: "Test User",
|
||||
Email: "test@example.com",
|
||||
}
|
||||
|
||||
// Table-driven test for SVG providers
|
||||
testCases := []struct {
|
||||
name string
|
||||
provider Provider
|
||||
}{
|
||||
{
|
||||
name: "Initials Provider",
|
||||
provider: &initials.Provider{},
|
||||
},
|
||||
{
|
||||
name: "Marble Provider",
|
||||
provider: &marble.Provider{},
|
||||
},
|
||||
{
|
||||
name: "Empty Provider",
|
||||
provider: &empty.Provider{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := tc.provider.AsDataURI(testUser, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(result, "data:image/svg+xml;base64,") {
|
||||
t.Errorf("Expected data URI with SVG base64, got: %s", result)
|
||||
}
|
||||
// Basic sanity check for reasonable length
|
||||
if len(result) < 50 {
|
||||
t.Errorf("Expected longer data URI, got: %s", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Gravatar Provider - Base64 Format", func(t *testing.T) {
|
||||
// Skip this test as it requires keyvalue store initialization
|
||||
// and network access to gravatar service
|
||||
t.Skip("Gravatar provider test requires full application setup")
|
||||
})
|
||||
}
|
||||
@@ -29,6 +29,11 @@ func (p *Provider) GetAvatar(user *user.User, size int64) (avatar []byte, mimeTy
|
||||
return up.GetAvatar(user, size)
|
||||
}
|
||||
|
||||
func (p *Provider) AsDataURI(user *user.User, size int64) (string, error) {
|
||||
up := upload.Provider{}
|
||||
return up.AsDataURI(user, size)
|
||||
}
|
||||
|
||||
func (p *Provider) FlushCache(u *user.User) error {
|
||||
up := upload.Provider{}
|
||||
return up.FlushCache(u)
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
package marble
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
@@ -123,3 +125,17 @@ func (p *Provider) GetAvatar(u *user.User, size int64) (avatar []byte, mimeType
|
||||
</defs>
|
||||
</svg>`), "image/svg+xml", nil
|
||||
}
|
||||
|
||||
// AsDataURI returns a data URI for the SVG avatar
|
||||
func (p *Provider) AsDataURI(u *user.User, size int64) (string, error) {
|
||||
avatarData, mimeType, err := p.GetAvatar(u, size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Encode the SVG as base64 and create a data URI
|
||||
base64Data := base64.StdEncoding.EncodeToString(avatarData)
|
||||
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
|
||||
|
||||
return dataURI, nil
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ func (p *Provider) GetAvatar(user *user.User, size int64) (avatar []byte, mimeTy
|
||||
return up.GetAvatar(user, size)
|
||||
}
|
||||
|
||||
func (p *Provider) AsDataURI(user *user.User, size int64) (string, error) {
|
||||
up := upload.Provider{}
|
||||
return up.AsDataURI(user, size)
|
||||
}
|
||||
|
||||
func (p *Provider) FlushCache(u *user.User) error {
|
||||
up := upload.Provider{}
|
||||
return up.FlushCache(u)
|
||||
|
||||
@@ -18,6 +18,7 @@ package upload
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
@@ -125,6 +126,20 @@ func (p *Provider) getAvatarWithDepth(u *user.User, size int64, recursionDepth i
|
||||
return cachedAvatar.Content, cachedAvatar.MimeType, nil
|
||||
}
|
||||
|
||||
// AsDataURI returns a base64 encoded data URI for the uploaded avatar
|
||||
func (p *Provider) AsDataURI(u *user.User, size int64) (string, error) {
|
||||
avatarData, mimeType, err := p.GetAvatar(u, size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Encode the avatar data as base64 and create a data URI
|
||||
base64Data := base64.StdEncoding.EncodeToString(avatarData)
|
||||
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
|
||||
|
||||
return dataURI, nil
|
||||
}
|
||||
|
||||
func StoreAvatarFile(s *xorm.Session, u *user.User, src io.Reader) (err error) {
|
||||
|
||||
// Remove the old file if one exists
|
||||
|
||||
Reference in New Issue
Block a user