feat: start custom preferences on backend
This commit is contained in:
		
							parent
							
								
									86a1841f4f
								
							
						
					
					
						commit
						7ea5efae93
					
				
					 8 changed files with 2118 additions and 39 deletions
				
			
		|  | @ -4,9 +4,12 @@ import ( | |||
| 	"context" | ||||
| 	"crypto/sha256" | ||||
| 	"encoding/hex" | ||||
| 	"fmt" | ||||
| 	"regexp" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/common" | ||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/icons" | ||||
| 	"emperror.dev/errors" | ||||
| 	"github.com/bwmarrin/discordgo" | ||||
| 	"github.com/georgysavva/scany/v2/pgxscan" | ||||
|  | @ -49,8 +52,47 @@ type User struct { | |||
| 	DeletedAt    *time.Time | ||||
| 	SelfDelete   *bool | ||||
| 	DeleteReason *string | ||||
| 
 | ||||
| 	CustomPreferences CustomPreferences | ||||
| } | ||||
| 
 | ||||
| type CustomPreferences = map[string]CustomPreference | ||||
| 
 | ||||
| type CustomPreference struct { | ||||
| 	Icon      string         `json:"icon"` | ||||
| 	Tooltip   string         `json:"tooltip"` | ||||
| 	Size      PreferenceSize `json:"size"` | ||||
| 	Muted     bool           `json:"muted"` | ||||
| 	Favourite bool           `json:"favourite"` | ||||
| } | ||||
| 
 | ||||
| func (c CustomPreference) Validate() string { | ||||
| 	if !icons.IsValid(c.Icon) { | ||||
| 		return fmt.Sprintf("custom preference icon %q is invalid", c.Icon) | ||||
| 	} | ||||
| 
 | ||||
| 	if c.Tooltip == "" { | ||||
| 		return "custom preference tooltip is empty" | ||||
| 	} | ||||
| 	if common.StringLength(&c.Tooltip) > FieldEntryMaxLength { | ||||
| 		return fmt.Sprintf("custom preference tooltip is too long, max %d characters, is %d characters", FieldEntryMaxLength, common.StringLength(&c.Tooltip)) | ||||
| 	} | ||||
| 
 | ||||
| 	if c.Size != PreferenceSizeLarge && c.Size != PreferenceSizeNormal && c.Size != PreferenceSizeSmall { | ||||
| 		return fmt.Sprintf("custom preference size %q is invalid", string(c.Size)) | ||||
| 	} | ||||
| 
 | ||||
| 	return "" | ||||
| } | ||||
| 
 | ||||
| type PreferenceSize string | ||||
| 
 | ||||
| const ( | ||||
| 	PreferenceSizeLarge  PreferenceSize = "large" | ||||
| 	PreferenceSizeNormal PreferenceSize = "normal" | ||||
| 	PreferenceSizeSmall  PreferenceSize = "small" | ||||
| ) | ||||
| 
 | ||||
| func (u User) NumProviders() (numProviders int) { | ||||
| 	if u.Discord != nil { | ||||
| 		numProviders++ | ||||
|  |  | |||
							
								
								
									
										1968
									
								
								backend/icons/icons.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1968
									
								
								backend/icons/icons.go
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -42,10 +42,11 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember boo | |||
| 		Fields:   db.NotNull(fields), | ||||
| 
 | ||||
| 		User: PartialUser{ | ||||
| 			ID:          u.ID, | ||||
| 			Username:    u.Username, | ||||
| 			DisplayName: u.DisplayName, | ||||
| 			Avatar:      u.Avatar, | ||||
| 			ID:                u.ID, | ||||
| 			Username:          u.Username, | ||||
| 			DisplayName:       u.DisplayName, | ||||
| 			Avatar:            u.Avatar, | ||||
| 			CustomPreferences: u.CustomPreferences, | ||||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
|  | @ -57,10 +58,11 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember boo | |||
| } | ||||
| 
 | ||||
| type PartialUser struct { | ||||
| 	ID          xid.ID  `json:"id"` | ||||
| 	Username    string  `json:"name"` | ||||
| 	DisplayName *string `json:"display_name"` | ||||
| 	Avatar      *string `json:"avatar"` | ||||
| 	ID                xid.ID               `json:"id"` | ||||
| 	Username          string               `json:"name"` | ||||
| 	DisplayName       *string              `json:"display_name"` | ||||
| 	Avatar            *string              `json:"avatar"` | ||||
| 	CustomPreferences db.CustomPreferences `json:"custom_preferences"` | ||||
| } | ||||
| 
 | ||||
| func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error { | ||||
|  |  | |||
|  | @ -12,17 +12,18 @@ import ( | |||
| ) | ||||
| 
 | ||||
| type GetUserResponse struct { | ||||
| 	ID          xid.ID            `json:"id"` | ||||
| 	Username    string            `json:"name"` | ||||
| 	DisplayName *string           `json:"display_name"` | ||||
| 	Bio         *string           `json:"bio"` | ||||
| 	MemberTitle *string           `json:"member_title"` | ||||
| 	Avatar      *string           `json:"avatar"` | ||||
| 	Links       []string          `json:"links"` | ||||
| 	Names       []db.FieldEntry   `json:"names"` | ||||
| 	Pronouns    []db.PronounEntry `json:"pronouns"` | ||||
| 	Members     []PartialMember   `json:"members"` | ||||
| 	Fields      []db.Field        `json:"fields"` | ||||
| 	ID                xid.ID               `json:"id"` | ||||
| 	Username          string               `json:"name"` | ||||
| 	DisplayName       *string              `json:"display_name"` | ||||
| 	Bio               *string              `json:"bio"` | ||||
| 	MemberTitle       *string              `json:"member_title"` | ||||
| 	Avatar            *string              `json:"avatar"` | ||||
| 	Links             []string             `json:"links"` | ||||
| 	Names             []db.FieldEntry      `json:"names"` | ||||
| 	Pronouns          []db.PronounEntry    `json:"pronouns"` | ||||
| 	Members           []PartialMember      `json:"members"` | ||||
| 	Fields            []db.Field           `json:"fields"` | ||||
| 	CustomPreferences db.CustomPreferences `json:"custom_preferences"` | ||||
| } | ||||
| 
 | ||||
| type GetMeResponse struct { | ||||
|  | @ -59,16 +60,17 @@ type PartialMember struct { | |||
| 
 | ||||
| func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUserResponse { | ||||
| 	resp := GetUserResponse{ | ||||
| 		ID:          u.ID, | ||||
| 		Username:    u.Username, | ||||
| 		DisplayName: u.DisplayName, | ||||
| 		Bio:         u.Bio, | ||||
| 		MemberTitle: u.MemberTitle, | ||||
| 		Avatar:      u.Avatar, | ||||
| 		Links:       db.NotNull(u.Links), | ||||
| 		Names:       db.NotNull(u.Names), | ||||
| 		Pronouns:    db.NotNull(u.Pronouns), | ||||
| 		Fields:      db.NotNull(fields), | ||||
| 		ID:                u.ID, | ||||
| 		Username:          u.Username, | ||||
| 		DisplayName:       u.DisplayName, | ||||
| 		Bio:               u.Bio, | ||||
| 		MemberTitle:       u.MemberTitle, | ||||
| 		Avatar:            u.Avatar, | ||||
| 		Links:             db.NotNull(u.Links), | ||||
| 		Names:             db.NotNull(u.Names), | ||||
| 		Pronouns:          db.NotNull(u.Pronouns), | ||||
| 		Fields:            db.NotNull(fields), | ||||
| 		CustomPreferences: u.CustomPreferences, | ||||
| 	} | ||||
| 
 | ||||
| 	resp.Members = make([]PartialMember, len(members)) | ||||
|  |  | |||
|  | @ -10,19 +10,21 @@ import ( | |||
| 	"codeberg.org/u1f320/pronouns.cc/backend/server" | ||||
| 	"emperror.dev/errors" | ||||
| 	"github.com/go-chi/render" | ||||
| 	"github.com/google/uuid" | ||||
| ) | ||||
| 
 | ||||
| type PatchUserRequest struct { | ||||
| 	Username    *string            `json:"username"` | ||||
| 	DisplayName *string            `json:"display_name"` | ||||
| 	Bio         *string            `json:"bio"` | ||||
| 	MemberTitle *string            `json:"member_title"` | ||||
| 	Links       *[]string          `json:"links"` | ||||
| 	Names       *[]db.FieldEntry   `json:"names"` | ||||
| 	Pronouns    *[]db.PronounEntry `json:"pronouns"` | ||||
| 	Fields      *[]db.Field        `json:"fields"` | ||||
| 	Avatar      *string            `json:"avatar"` | ||||
| 	ListPrivate *bool              `json:"list_private"` | ||||
| 	Username          *string               `json:"username"` | ||||
| 	DisplayName       *string               `json:"display_name"` | ||||
| 	Bio               *string               `json:"bio"` | ||||
| 	MemberTitle       *string               `json:"member_title"` | ||||
| 	Links             *[]string             `json:"links"` | ||||
| 	Names             *[]db.FieldEntry      `json:"names"` | ||||
| 	Pronouns          *[]db.PronounEntry    `json:"pronouns"` | ||||
| 	Fields            *[]db.Field           `json:"fields"` | ||||
| 	Avatar            *string               `json:"avatar"` | ||||
| 	ListPrivate       *bool                 `json:"list_private"` | ||||
| 	CustomPreferences *db.CustomPreferences `json:"custom_preferences"` | ||||
| } | ||||
| 
 | ||||
| // patchUser parses a PatchUserRequest and updates the user with the given ID. | ||||
|  | @ -115,6 +117,19 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { | |||
| 		return *err | ||||
| 	} | ||||
| 
 | ||||
| 	// validate custom preferences | ||||
| 	if req.CustomPreferences != nil { | ||||
| 		for k, v := range *req.CustomPreferences { | ||||
| 			_, err := uuid.Parse(k) | ||||
| 			if err != nil { | ||||
| 				return server.APIError{Code: server.ErrBadRequest, Details: "One or more custom preference IDs is not a UUID."} | ||||
| 			} | ||||
| 			if s := v.Validate(); s != "" { | ||||
| 				return server.APIError{Code: server.ErrBadRequest, Details: s} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// update avatar | ||||
| 	var avatarHash *string = nil | ||||
| 	if req.Avatar != nil { | ||||
|  |  | |||
							
								
								
									
										44
									
								
								frontend/icons.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								frontend/icons.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,44 @@ | |||
| // This script regenerates the list of icons for the frontend (frontend/src/icons.json)
 | ||||
| // and the backend (backend/icons/icons.go) from the currently installed version of Bootstrap Icons.
 | ||||
| // Run with `pnpm node icons.js` in the frontend directory.
 | ||||
| 
 | ||||
| import { writeFileSync } from "fs"; | ||||
| import icons from "bootstrap-icons/font/bootstrap-icons.json" assert { type: "json" }; | ||||
| 
 | ||||
| const keys = Object.keys(icons); | ||||
| 
 | ||||
| console.log(`Found ${keys.length} icons`); | ||||
| const output = JSON.stringify(keys); | ||||
| console.log(`Saving file as src/icons.json`); | ||||
| 
 | ||||
| writeFileSync("src/icons.json", output); | ||||
| 
 | ||||
| const goCode1 = `// Generated code. DO NOT EDIT
 | ||||
| package icons | ||||
| 
 | ||||
| var icons = [...]string{ | ||||
| `;
 | ||||
| 
 | ||||
| const goCode2 = `}
 | ||||
| 
 | ||||
| // IsValid returns true if the input is the name of a Bootstrap icon.
 | ||||
| func IsValid(name string) bool { | ||||
| 	for i := range icons { | ||||
| 		if icons[i] == name { | ||||
| 			return true | ||||
| 		} | ||||
| 	} | ||||
| 	return false | ||||
| } | ||||
| `;
 | ||||
| 
 | ||||
| let goOutput = goCode1; | ||||
| 
 | ||||
| keys.forEach((element) => { | ||||
|   goOutput += `	"${element}",\n`; | ||||
| }); | ||||
| 
 | ||||
| goOutput += goCode2; | ||||
| 
 | ||||
| console.log("Writing Go code"); | ||||
| writeFileSync("../backend/icons/icons.go", goOutput); | ||||
							
								
								
									
										1
									
								
								frontend/src/icons.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/icons.json
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										5
									
								
								scripts/migrate/015_custom_preferences.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								scripts/migrate/015_custom_preferences.sql
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| -- +migrate Up | ||||
| 
 | ||||
| -- 2023-04-19: Add custom preferences | ||||
| 
 | ||||
| alter table users add column custom_preferences jsonb not null default '{}'; | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue