diff --git a/config/constants.go b/config/constants.go index d851f75a1..a2acc7760 100644 --- a/config/constants.go +++ b/config/constants.go @@ -11,6 +11,9 @@ const ( DataDirectory = "data" // EmojiDir is relative to the static directory. EmojiDir = "/img/emoji" + // MaxUserColor is the largest color value available to assign to users. + // They start at 0 and can be treated as IDs more than colors themselves. + MaxUserColor = 7 ) var ( diff --git a/controllers/admin/externalAPIUsers.go b/controllers/admin/externalAPIUsers.go index 3e932936b..b0a281eb6 100644 --- a/controllers/admin/externalAPIUsers.go +++ b/controllers/admin/externalAPIUsers.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "github.com/owncast/owncast/config" "github.com/owncast/owncast/controllers" "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/utils" @@ -41,7 +42,7 @@ func CreateExternalAPIUser(w http.ResponseWriter, r *http.Request) { return } - color := utils.GenerateRandomDisplayColor() + color := utils.GenerateRandomDisplayColor(config.MaxUserColor) if err := user.InsertExternalAPIUser(token, request.Name, color, request.Scopes); err != nil { controllers.InternalErrorHandler(w, err) diff --git a/core/chat/events.go b/core/chat/events.go index da7c19cc9..454d02d92 100644 --- a/core/chat/events.go +++ b/core/chat/events.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/owncast/owncast/config" "github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/user" @@ -91,6 +92,25 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { eventData.client.sendConnectedClientInfo() } +func (s *Server) userColorChanged(eventData chatClientEvent) { + var receivedEvent events.ColorChangeEvent + if err := json.Unmarshal(eventData.data, &receivedEvent); err != nil { + log.Errorln("error unmarshalling to ColorChangeEvent", err) + return + } + + // Verify this color is valid + if receivedEvent.NewColor > config.MaxUserColor { + log.Errorln("invalid color requested when changing user display color") + return + } + + // Save the new color + if err := user.ChangeUserColor(eventData.client.User.ID, receivedEvent.NewColor); err != nil { + log.Errorln("error changing user display color", err) + } +} + func (s *Server) userMessageSent(eventData chatClientEvent) { var event events.UserMessageEvent if err := json.Unmarshal(eventData.data, &event); err != nil { diff --git a/core/chat/events/eventtype.go b/core/chat/events/eventtype.go index a0d15b63a..2026c85c6 100644 --- a/core/chat/events/eventtype.go +++ b/core/chat/events/eventtype.go @@ -10,6 +10,8 @@ const ( UserJoined EventType = "USER_JOINED" // UserNameChanged is the event sent when a chat username change takes place. UserNameChanged EventType = "NAME_CHANGE" + // UserColorChanged is the event sent when a chat user color change takes place. + UserColorChanged EventType = "COLOR_CHANGE" // VisibiltyUpdate is the event sent when a chat message's visibility changes. VisibiltyUpdate EventType = "VISIBILITY-UPDATE" // PING is a ping message. diff --git a/core/chat/events/nameChangeEvent.go b/core/chat/events/nameChangeEvent.go index 379db0adf..e5659a18f 100644 --- a/core/chat/events/nameChangeEvent.go +++ b/core/chat/events/nameChangeEvent.go @@ -7,6 +7,13 @@ type NameChangeEvent struct { NewName string `json:"newName"` } +// ColorChangeEvent is received when a user changes their chat display color. +type ColorChangeEvent struct { + Event + UserEvent + NewColor int `json:"newColor"` +} + // NameChangeBroadcast represents a user changing their chat display name. type NameChangeBroadcast struct { Event diff --git a/core/chat/server.go b/core/chat/server.go index 19d071b05..babe332ea 100644 --- a/core/chat/server.go +++ b/core/chat/server.go @@ -359,6 +359,8 @@ func (s *Server) eventReceived(event chatClientEvent) { case events.UserNameChanged: s.userNameChanged(event) + case events.UserColorChanged: + s.userColorChanged(event) default: log.Debugln(eventType, "event not found:", typecheck) } diff --git a/core/data/migrations.go b/core/data/migrations.go index 817010bff..cb63fd375 100644 --- a/core/data/migrations.go +++ b/core/data/migrations.go @@ -291,7 +291,7 @@ func migrateToSchema1(db *sql.DB) { // Recreate them as users for _, token := range oldAccessTokens { - color := utils.GenerateRandomDisplayColor() + color := utils.GenerateRandomDisplayColor(config.MaxUserColor) if err := insertAPIToken(db, token.accessToken, token.displayName, color, token.scopes); err != nil { log.Errorln("Error migrating access token", err) } diff --git a/core/user/user.go b/core/user/user.go index 759e3009d..8ee99482b 100644 --- a/core/user/user.go +++ b/core/user/user.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/owncast/owncast/config" "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/db" "github.com/owncast/owncast/utils" @@ -70,7 +71,7 @@ func CreateAnonymousUser(displayName string) (*User, string, error) { } } - displayColor := utils.GenerateRandomDisplayColor() + displayColor := utils.GenerateRandomDisplayColor(config.MaxUserColor) user := &User{ ID: id, @@ -125,6 +126,21 @@ func ChangeUsername(userID string, username string) error { return nil } +// ChangeUserColor will change the user associated to userID from one display name to another. +func ChangeUserColor(userID string, color int) error { + _datastore.DbLock.Lock() + defer _datastore.DbLock.Unlock() + + if err := _datastore.GetQueries().ChangeDisplayColor(context.Background(), db.ChangeDisplayColorParams{ + DisplayColor: int32(color), + ID: userID, + }); err != nil { + return errors.Wrap(err, "unable to change display color") + } + + return nil +} + func addAccessTokenForUser(accessToken, userID string) error { return _datastore.GetQueries().AddAccessTokenForUser(context.Background(), db.AddAccessTokenForUserParams{ Token: accessToken, diff --git a/db/db.go b/db/db.go index f30b89ec0..21fc4346d 100644 --- a/db/db.go +++ b/db/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.14.0 +// sqlc v1.15.0 package db diff --git a/db/models.go b/db/models.go index 369309471..f5c13b7b2 100644 --- a/db/models.go +++ b/db/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.14.0 +// sqlc v1.15.0 package db diff --git a/db/query.sql b/db/query.sql index cb29c3b14..03e9ed446 100644 --- a/db/query.sql +++ b/db/query.sql @@ -105,3 +105,6 @@ SELECT count(*) FROM users WHERE display_name = $1 AND authenticated_at is not n -- name: ChangeDisplayName :exec UPDATE users SET display_name = $1, previous_names = previous_names || $2, namechanged_at = $3 WHERE id = $4; + +-- name: ChangeDisplayColor :exec +UPDATE users SET display_color = $1 WHERE id = $2; diff --git a/db/query.sql.go b/db/query.sql.go index 4b966b650..97f73a665 100644 --- a/db/query.sql.go +++ b/db/query.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.14.0 +// sqlc v1.15.0 // source: query.sql package db @@ -153,6 +153,20 @@ func (q *Queries) BanIPAddress(ctx context.Context, arg BanIPAddressParams) erro return err } +const changeDisplayColor = `-- name: ChangeDisplayColor :exec +UPDATE users SET display_color = $1 WHERE id = $2 +` + +type ChangeDisplayColorParams struct { + DisplayColor int32 + ID string +} + +func (q *Queries) ChangeDisplayColor(ctx context.Context, arg ChangeDisplayColorParams) error { + _, err := q.db.ExecContext(ctx, changeDisplayColor, arg.DisplayColor, arg.ID) + return err +} + const changeDisplayName = `-- name: ChangeDisplayName :exec UPDATE users SET display_name = $1, previous_names = previous_names || $2, namechanged_at = $3 WHERE id = $4 ` diff --git a/utils/utils.go b/utils/utils.go index 95e680dc4..4cce78d70 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -324,10 +324,10 @@ func StringMapKeys(stringMap map[string]interface{}) []string { // GenerateRandomDisplayColor will return a random number that is used for // referencing a color value client-side. These colors are seen as // --theme-user-colors-n. -func GenerateRandomDisplayColor() int { - rangeLower := 1 - rangeUpper := 8 - return rangeLower + rand.Intn(rangeUpper-rangeLower+1) //nolint +func GenerateRandomDisplayColor(maxColor int) int { + rangeLower := 0 + rangeUpper := maxColor + return rangeLower + rand.Intn(rangeUpper-rangeLower+1) //nolint:gosec } // GetHostnameFromURL will return the hostname component from a URL string. diff --git a/web/components/modals/NameChangeModal.tsx b/web/components/modals/NameChangeModal.tsx index e851bfa68..3942124b9 100644 --- a/web/components/modals/NameChangeModal.tsx +++ b/web/components/modals/NameChangeModal.tsx @@ -1,16 +1,34 @@ -import { useState } from 'react'; +import React, { CSSProperties, useState } from 'react'; import { useRecoilValue } from 'recoil'; -import { Input, Button } from 'antd'; +import { Input, Button, Select } from 'antd'; import { MessageType } from '../../interfaces/socket-events'; import WebsocketService from '../../services/websocket-service'; -import { websocketServiceAtom, chatDisplayNameAtom } from '../stores/ClientConfigStore'; +import { + websocketServiceAtom, + chatDisplayNameAtom, + chatDisplayColorAtom, +} from '../stores/ClientConfigStore'; + +const { Option } = Select; /* eslint-disable @typescript-eslint/no-unused-vars */ interface Props {} +function UserColor(props: { color: number }): React.ReactElement { + const { color } = props; + const style: CSSProperties = { + textAlign: 'center', + backgroundColor: `var(--theme-user-colors-${color})`, + width: '100%', + height: '100%', + }; + return
; +} + export default function NameChangeModal(props: Props) { const websocketService = useRecoilValue(websocketServiceAtom); const chatDisplayName = useRecoilValue(chatDisplayNameAtom); + const chatDisplayColor = useRecoilValue(chatDisplayColorAtom); const [newName, setNewName] = useState(chatDisplayName); const handleNameChange = () => { @@ -24,6 +42,17 @@ export default function NameChangeModal(props: Props) { const saveEnabled = newName !== chatDisplayName && newName !== '' && websocketService?.isConnected(); + const handleColorChange = (color: string) => { + const colorChange = { + type: MessageType.COLOR_CHANGE, + newColor: Number(color), + }; + websocketService.send(colorChange); + }; + + const maxColor = 8; // 0...n + const colorOptions = [...Array(maxColor)].map((e, i) => i); + return (
Your chat display name is what people see when you send chat messages. Other information can @@ -32,13 +61,28 @@ export default function NameChangeModal(props: Props) { value={newName} onChange={e => setNewName(e.target.value)} placeholder="Your chat display name" - maxLength={10} + maxLength={30} showCount defaultValue={chatDisplayName} /> +
+ Your Color + +
); } diff --git a/web/components/stores/ClientConfigStore.tsx b/web/components/stores/ClientConfigStore.tsx index ef056f579..880c1ac4c 100644 --- a/web/components/stores/ClientConfigStore.tsx +++ b/web/components/stores/ClientConfigStore.tsx @@ -46,6 +46,11 @@ export const chatDisplayNameAtom = atom({ default: null, }); +export const chatDisplayColorAtom = atom({ + key: 'chatDisplayColor', + default: null, +}); + export const chatUserIdAtom = atom({ key: 'chatUserIdAtom', default: null, @@ -149,6 +154,7 @@ export function ClientConfigStore() { const [appState, appStateSend, appStateService] = useMachine(appStateModel); const setChatDisplayName = useSetRecoilState(chatDisplayNameAtom); + const setChatDisplayColor = useSetRecoilState(chatDisplayColorAtom); const setChatUserId = useSetRecoilState(chatUserIdAtom); const setIsChatModerator = useSetRecoilState(isChatModeratorAtom); const setClientConfig = useSetRecoilState(clientConfigStateAtom); @@ -225,7 +231,7 @@ export function ClientConfigStore() { sendEvent(AppStateEvent.NeedsRegister); const response = await ChatService.registerUser(optionalDisplayName); console.log(`ChatService -> registerUser() response: \n${response}`); - const { accessToken: newAccessToken, displayName: newDisplayName } = response; + const { accessToken: newAccessToken, displayName: newDisplayName, displayColor } = response; if (!newAccessToken) { return; } @@ -234,6 +240,7 @@ export function ClientConfigStore() { setAccessToken(newAccessToken); setLocalStorage(ACCESS_TOKEN_KEY, newAccessToken); setChatDisplayName(newDisplayName); + setChatDisplayColor(displayColor); } catch (e) { sendEvent(AppStateEvent.Fail); console.error(`ChatService -> registerUser() ERROR: \n${e}`); @@ -255,6 +262,7 @@ export function ClientConfigStore() { handleConnectedClientInfoMessage( message as ConnectedClientInfoEvent, setChatDisplayName, + setChatDisplayColor, setChatUserId, setIsChatModerator, ); diff --git a/web/components/stores/eventhandlers/connected-client-info-handler.ts b/web/components/stores/eventhandlers/connected-client-info-handler.ts index fe6454ec3..6063a5480 100644 --- a/web/components/stores/eventhandlers/connected-client-info-handler.ts +++ b/web/components/stores/eventhandlers/connected-client-info-handler.ts @@ -3,12 +3,14 @@ import { ConnectedClientInfoEvent } from '../../../interfaces/socket-events'; export default function handleConnectedClientInfoMessage( message: ConnectedClientInfoEvent, setChatDisplayName: (string) => void, + setChatDisplayColor: (number) => void, setChatUserId: (number) => void, setIsChatModerator: (boolean) => void, ) { const { user } = message; - const { id, displayName, scopes } = user; + const { id, displayName, displayColor, scopes } = user; setChatDisplayName(displayName); + setChatDisplayColor(displayColor); setChatUserId(id); setIsChatModerator(scopes?.includes('moderator')); } diff --git a/web/interfaces/socket-events.ts b/web/interfaces/socket-events.ts index 46beb88de..1672ae712 100644 --- a/web/interfaces/socket-events.ts +++ b/web/interfaces/socket-events.ts @@ -4,6 +4,7 @@ export enum MessageType { CHAT = 'CHAT', PING = 'PING', NAME_CHANGE = 'NAME_CHANGE', + COLOR_CHANGE = 'COLOR_CHANGE', PONG = 'PONG', SYSTEM = 'SYSTEM', USER_JOINED = 'USER_JOINED', diff --git a/web/services/chat-service.ts b/web/services/chat-service.ts index c84cab452..b287fc83f 100644 --- a/web/services/chat-service.ts +++ b/web/services/chat-service.ts @@ -8,6 +8,7 @@ interface UserRegistrationResponse { id: string; accessToken: string; displayName: string; + displayColor: number; } class ChatService { diff --git a/web/stories/Colors.stories.mdx b/web/stories/Colors.stories.mdx index e111e891a..43e3a2f8a 100644 --- a/web/stories/Colors.stories.mdx +++ b/web/stories/Colors.stories.mdx @@ -22,6 +22,7 @@ Toggle dark mode on and off in the above toolbar to see how these colors look on diff --git a/web/style-definitions/tokens/color/default-theme.yaml b/web/style-definitions/tokens/color/default-theme.yaml index 0c1616389..2d1dbaf38 100644 --- a/web/style-definitions/tokens/color/default-theme.yaml +++ b/web/style-definitions/tokens/color/default-theme.yaml @@ -36,19 +36,19 @@ theme: user-colors: comment: 'Colors used to display chat users.' - 1: + 0: value: '{color.owncast.user.1.value}' - 2: + 1: value: '{color.owncast.user.2.value}' - 3: + 2: value: '{color.owncast.user.3.value}' - 4: + 3: value: '{color.owncast.user.4.value}' - 5: + 4: value: '{color.owncast.user.5.value}' - 6: + 5: value: '{color.owncast.user.6.value}' - 7: + 6: value: '{color.owncast.user.7.value}' - 8: + 7: value: '{color.owncast.user.8.value}' diff --git a/web/styles/theme.less b/web/styles/theme.less index 712308d4c..aaef2fcdb 100644 --- a/web/styles/theme.less +++ b/web/styles/theme.less @@ -1,6 +1,6 @@ // Do not edit directly -// Generated on Tue, 12 Jul 2022 21:03:36 GMT +// Generated on Wed, 10 Aug 2022 02:31:18 GMT // // How to edit these values: // Edit the corresponding token file under the style-definitions directory @@ -26,14 +26,14 @@ @theme-background-primary: #fffcf2; // The main background color of the page. @theme-background-secondary: #f0efe4; // A secondary background color used in sections and controls. @theme-rounded-corners: 0.5em; -@theme-user-colors-1: #f40b0b; -@theme-user-colors-2: #f4800b; -@theme-user-colors-3: #f4f40b; -@theme-user-colors-4: #58f40b; -@theme-user-colors-5: #0bf4f4; -@theme-user-colors-6: #0ba6f4; -@theme-user-colors-7: #6666ff; -@theme-user-colors-8: #f40bf4; +@theme-user-colors-0: #f40b0b; +@theme-user-colors-1: #f4800b; +@theme-user-colors-2: #f4f40b; +@theme-user-colors-3: #58f40b; +@theme-user-colors-4: #0bf4f4; +@theme-user-colors-5: #0ba6f4; +@theme-user-colors-6: #6666ff; +@theme-user-colors-7: #f40bf4; @owncast-purple: #00ff00; @owncast-purple-25: rgba(120, 113, 255, 0.25); @owncast-purple-50: rgba(120, 113, 255, 0.5); diff --git a/web/styles/variables.css b/web/styles/variables.css index 29b94d6ea..10647d4e3 100644 --- a/web/styles/variables.css +++ b/web/styles/variables.css @@ -1,6 +1,6 @@ /** * Do not edit directly - * Generated on Tue, 12 Jul 2022 21:03:36 GMT + * Generated on Wed, 10 Aug 2022 02:31:18 GMT * * How to edit these values: * Edit the corresponding token file under the style-definitions directory @@ -23,23 +23,19 @@ --theme-text-primary: #030208; /* The color of the text in the application. */ --theme-text-secondary: #63638e; --theme-text-link: #5353a6; - --theme-text-body-font-family: 'Open Sans', system-ui, -apple-system, BlinkMacSystemFont, - 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', - 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --theme-text-display-font-family: 'Poppins', system-ui, -apple-system, BlinkMacSystemFont, - 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', - 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --theme-text-body-font-family: 'Open Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --theme-text-display-font-family: 'Poppins', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --theme-background-primary: #fffcf2; /* The main background color of the page. */ --theme-background-secondary: #f0efe4; /* A secondary background color used in sections and controls. */ --theme-rounded-corners: 0.5em; - --theme-user-colors-1: #f40b0b; - --theme-user-colors-2: #f4800b; - --theme-user-colors-3: #f4f40b; - --theme-user-colors-4: #58f40b; - --theme-user-colors-5: #0bf4f4; - --theme-user-colors-6: #0ba6f4; - --theme-user-colors-7: #6666ff; - --theme-user-colors-8: #f40bf4; + --theme-user-colors-0: #f40b0b; + --theme-user-colors-1: #f4800b; + --theme-user-colors-2: #f4f40b; + --theme-user-colors-3: #58f40b; + --theme-user-colors-4: #0bf4f4; + --theme-user-colors-5: #0ba6f4; + --theme-user-colors-6: #6666ff; + --theme-user-colors-7: #f40bf4; --owncast-purple: #00ff00; --owncast-purple-25: rgba(120, 113, 255, 0.25); --owncast-purple-50: rgba(120, 113, 255, 0.5); @@ -71,10 +67,6 @@ --color-owncast-background-primary: #fffcf2; --color-owncast-background-secondary: #f0efe4; --rounded-corners: 0.5em; - --font-owncast-body: 'Open Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, - 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', - 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-owncast-display: 'Poppins', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', - Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', - 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-owncast-body: 'Open Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-owncast-display: 'Poppins', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; }