mirror of
				https://github.com/owncast/owncast.git
				synced 2025-10-31 18:18:06 +08:00 
			
		
		
		
	Feature: emoji editor (#2411)
* Custom emoji editor: implement backend This reuses the logo upload code * Implement emoji edit admin interface Again reuse base64 logic from the logo upload * Allow toggling between uploaded and default emojis * Add route that always serves uploaded emojis This is needed for the admin emoji interface, as otherwise the emojis will 404 if custom emojis are disabled * Fix linter warnings * Remove custom/uploaded emoji logic * Reset timer after emoji deletion * Setup: copy built-in emojis to emoji directory
This commit is contained in:
		| @ -9,8 +9,8 @@ const ( | ||||
| 	FfmpegSuggestedVersion = "v4.1.5" // Requires the v | ||||
| 	// DataDirectory is the directory we save data to. | ||||
| 	DataDirectory = "data" | ||||
| 	// EmojiDir is relative to the static directory. | ||||
| 	EmojiDir = "/img/emoji" | ||||
| 	// EmojiDir defines the URL route prefix for emoji requests. | ||||
| 	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 | ||||
| @ -25,6 +25,6 @@ var ( | ||||
| 	// HLSStoragePath is the directory HLS video is written to. | ||||
| 	HLSStoragePath = filepath.Join(DataDirectory, "hls") | ||||
|  | ||||
| 	// CustomEmojiPath is the optional emoji override directory. | ||||
| 	// CustomEmojiPath is the emoji directory. | ||||
| 	CustomEmojiPath = filepath.Join(DataDirectory, "emoji") | ||||
| ) | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| package admin | ||||
|  | ||||
| import ( | ||||
| 	"encoding/base64" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"net" | ||||
| @ -228,39 +227,12 @@ func SetLogo(w http.ResponseWriter, r *http.Request) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	s := strings.SplitN(configValue.Value.(string), ",", 2) | ||||
| 	if len(s) < 2 { | ||||
| 		controllers.WriteSimpleResponse(w, false, "Error splitting base64 image data.") | ||||
| 		return | ||||
| 	} | ||||
| 	bytes, err := base64.StdEncoding.DecodeString(s[1]) | ||||
| 	bytes, extension, err := utils.DecodeBase64Image(configValue.Value.(string)) | ||||
| 	if err != nil { | ||||
| 		controllers.WriteSimpleResponse(w, false, err.Error()) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	splitHeader := strings.Split(s[0], ":") | ||||
| 	if len(splitHeader) < 2 { | ||||
| 		controllers.WriteSimpleResponse(w, false, "Error splitting base64 image header.") | ||||
| 		return | ||||
| 	} | ||||
| 	contentType := strings.Split(splitHeader[1], ";")[0] | ||||
| 	extension := "" | ||||
| 	if contentType == "image/svg+xml" { | ||||
| 		extension = ".svg" | ||||
| 	} else if contentType == "image/gif" { | ||||
| 		extension = ".gif" | ||||
| 	} else if contentType == "image/png" { | ||||
| 		extension = ".png" | ||||
| 	} else if contentType == "image/jpeg" { | ||||
| 		extension = ".jpeg" | ||||
| 	} | ||||
|  | ||||
| 	if extension == "" { | ||||
| 		controllers.WriteSimpleResponse(w, false, "Missing or invalid contentType in base64 image.") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	imgPath := filepath.Join("data", "logo"+extension) | ||||
| 	if err := os.WriteFile(imgPath, bytes, 0o600); err != nil { | ||||
| 		controllers.WriteSimpleResponse(w, false, err.Error()) | ||||
|  | ||||
							
								
								
									
										92
									
								
								controllers/admin/emoji.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								controllers/admin/emoji.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,92 @@ | ||||
| package admin | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
|  | ||||
| 	"github.com/owncast/owncast/config" | ||||
| 	"github.com/owncast/owncast/controllers" | ||||
| 	"github.com/owncast/owncast/utils" | ||||
| ) | ||||
|  | ||||
| // UploadCustomEmoji allows POSTing a new custom emoji to the server. | ||||
| func UploadCustomEmoji(w http.ResponseWriter, r *http.Request) { | ||||
| 	if !requirePOST(w, r) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	type postEmoji struct { | ||||
| 		Name string `json:"name"` | ||||
| 		Data string `json:"data"` | ||||
| 	} | ||||
|  | ||||
| 	emoji := new(postEmoji) | ||||
|  | ||||
| 	if err := json.NewDecoder(r.Body).Decode(emoji); err != nil { | ||||
| 		controllers.WriteSimpleResponse(w, false, err.Error()) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	bytes, _, err := utils.DecodeBase64Image(emoji.Data) | ||||
| 	if err != nil { | ||||
| 		controllers.WriteSimpleResponse(w, false, err.Error()) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// Prevent path traversal attacks | ||||
| 	var emojiFileName = filepath.Base(emoji.Name) | ||||
| 	var targetPath = filepath.Join(config.CustomEmojiPath, emojiFileName) | ||||
|  | ||||
| 	err = os.MkdirAll(config.CustomEmojiPath, 0700) | ||||
| 	if err != nil { | ||||
| 		controllers.WriteSimpleResponse(w, false, err.Error()) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if utils.DoesFileExists(targetPath) { | ||||
| 		controllers.WriteSimpleResponse(w, false, fmt.Sprintf("An emoji with the name %q already exists", emojiFileName)) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err = os.WriteFile(targetPath, bytes, 0o600); err != nil { | ||||
| 		controllers.WriteSimpleResponse(w, false, err.Error()) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	controllers.WriteSimpleResponse(w, true, fmt.Sprintf("Emoji %q has been uploaded", emojiFileName)) | ||||
| } | ||||
|  | ||||
| // DeleteCustomEmoji deletes a custom emoji. | ||||
| func DeleteCustomEmoji(w http.ResponseWriter, r *http.Request) { | ||||
| 	if !requirePOST(w, r) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	type deleteEmoji struct { | ||||
| 		Name string `json:"name"` | ||||
| 	} | ||||
|  | ||||
| 	emoji := new(deleteEmoji) | ||||
|  | ||||
| 	if err := json.NewDecoder(r.Body).Decode(emoji); err != nil { | ||||
| 		controllers.WriteSimpleResponse(w, false, err.Error()) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	var emojiFileName = filepath.Base(emoji.Name) | ||||
| 	var targetPath = filepath.Join(config.CustomEmojiPath, emojiFileName) | ||||
|  | ||||
| 	if err := os.Remove(targetPath); err != nil { | ||||
| 		if os.IsNotExist(err) { | ||||
| 			controllers.WriteSimpleResponse(w, false, fmt.Sprintf("Emoji %q doesn't exist", emojiFileName)) | ||||
| 		} else { | ||||
| 			controllers.WriteSimpleResponse(w, false, err.Error()) | ||||
| 		} | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	controllers.WriteSimpleResponse(w, true, fmt.Sprintf("Emoji %q has been deleted", emojiFileName)) | ||||
| } | ||||
| @ -2,50 +2,17 @@ package controllers | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"io/fs" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/owncast/owncast/config" | ||||
| 	"github.com/owncast/owncast/models" | ||||
| 	"github.com/owncast/owncast/static" | ||||
| 	"github.com/owncast/owncast/utils" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"github.com/owncast/owncast/core/data" | ||||
| ) | ||||
|  | ||||
| var useCustomEmojiDirectory = utils.DoesFileExists(config.CustomEmojiPath) | ||||
|  | ||||
| // getCustomEmojiList returns a list of custom emoji either from the cache or from the emoji directory. | ||||
| func getCustomEmojiList() []models.CustomEmoji { | ||||
| 	var emojiFS fs.FS | ||||
| 	if useCustomEmojiDirectory { | ||||
| 		emojiFS = os.DirFS(config.CustomEmojiPath) | ||||
| 	} else { | ||||
| 		emojiFS = static.GetEmoji() | ||||
| 	} | ||||
|  | ||||
| 	emojiResponse := make([]models.CustomEmoji, 0) | ||||
|  | ||||
| 	files, err := fs.Glob(emojiFS, "*") | ||||
| 	if err != nil { | ||||
| 		log.Errorln(err) | ||||
| 		return emojiResponse | ||||
| 	} | ||||
|  | ||||
| 	for _, name := range files { | ||||
| 		emojiPath := filepath.Join(config.EmojiDir, name) | ||||
| 		singleEmoji := models.CustomEmoji{Name: name, URL: emojiPath} | ||||
| 		emojiResponse = append(emojiResponse, singleEmoji) | ||||
| 	} | ||||
|  | ||||
| 	return emojiResponse | ||||
| } | ||||
|  | ||||
| // GetCustomEmojiList returns a list of custom emoji via the API. | ||||
| // GetCustomEmojiList returns a list of emoji via the API. | ||||
| func GetCustomEmojiList(w http.ResponseWriter, r *http.Request) { | ||||
| 	emojiList := getCustomEmojiList() | ||||
| 	emojiList := data.GetEmojiList() | ||||
|  | ||||
| 	if err := json.NewEncoder(w).Encode(emojiList); err != nil { | ||||
| 		InternalErrorHandler(w, err) | ||||
| @ -57,13 +24,6 @@ func GetCustomEmojiImage(w http.ResponseWriter, r *http.Request) { | ||||
| 	path := strings.TrimPrefix(r.URL.Path, "/img/emoji/") | ||||
| 	r.URL.Path = path | ||||
|  | ||||
| 	var emojiStaticServer http.Handler | ||||
| 	if useCustomEmojiDirectory { | ||||
| 		emojiFS := os.DirFS(config.CustomEmojiPath) | ||||
| 		emojiStaticServer = http.FileServer(http.FS(emojiFS)) | ||||
| 	} else { | ||||
| 		emojiStaticServer = http.FileServer(http.FS(static.GetEmoji())) | ||||
| 	} | ||||
|  | ||||
| 	emojiStaticServer.ServeHTTP(w, r) | ||||
| 	emojiFS := os.DirFS(config.CustomEmojiPath) | ||||
| 	http.FileServer(http.FS(emojiFS)).ServeHTTP(w, r) | ||||
| } | ||||
|  | ||||
							
								
								
									
										84
									
								
								core/data/emoji.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								core/data/emoji.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,84 @@ | ||||
| package data | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"io/fs" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
|  | ||||
| 	"github.com/owncast/owncast/config" | ||||
| 	"github.com/owncast/owncast/models" | ||||
| 	"github.com/owncast/owncast/static" | ||||
| 	"github.com/owncast/owncast/utils" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| ) | ||||
|  | ||||
| // GetEmojiList returns a list of custom emoji from the emoji directory. | ||||
| func GetEmojiList() []models.CustomEmoji { | ||||
| 	var emojiFS = os.DirFS(config.CustomEmojiPath) | ||||
|  | ||||
| 	emojiResponse := make([]models.CustomEmoji, 0) | ||||
|  | ||||
| 	files, err := fs.Glob(emojiFS, "*") | ||||
| 	if err != nil { | ||||
| 		log.Errorln(err) | ||||
| 		return emojiResponse | ||||
| 	} | ||||
|  | ||||
| 	for _, name := range files { | ||||
| 		emojiPath := filepath.Join(config.EmojiDir, name) | ||||
| 		singleEmoji := models.CustomEmoji{Name: name, URL: emojiPath} | ||||
| 		emojiResponse = append(emojiResponse, singleEmoji) | ||||
| 	} | ||||
|  | ||||
| 	return emojiResponse | ||||
| } | ||||
|  | ||||
| // SetupEmojiDirectory sets up the custom emoji directory by copying all built-in | ||||
| // emojis if the directory does not yet exist. | ||||
| func SetupEmojiDirectory() (err error) { | ||||
| 	if utils.DoesFileExists(config.CustomEmojiPath) { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if err = os.MkdirAll(config.CustomEmojiPath, 0o750); err != nil { | ||||
| 		return fmt.Errorf("unable to create custom emoji directory: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	staticFS := static.GetEmoji() | ||||
| 	files, err := fs.Glob(staticFS, "*") | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("unable to read built-in emoji files: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// Now copy all built-in emojis to the custom emoji directory | ||||
| 	for _, name := range files { | ||||
| 		emojiPath := filepath.Join(config.CustomEmojiPath, filepath.Base(name)) | ||||
|  | ||||
| 		// nolint:gosec | ||||
| 		diskFile, err := os.Create(emojiPath) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("unable to create custom emoji file on disk: %w", err) | ||||
| 		} | ||||
|  | ||||
| 		memFile, err := staticFS.Open(name) | ||||
| 		if err != nil { | ||||
| 			_ = diskFile.Close() | ||||
| 			return fmt.Errorf("unable to open built-in emoji file: %w", err) | ||||
| 		} | ||||
|  | ||||
| 		if _, err = io.Copy(diskFile, memFile); err != nil { | ||||
| 			_ = diskFile.Close() | ||||
| 			_ = os.Remove(emojiPath) | ||||
| 			return fmt.Errorf("unable to copy built-in emoji file to disk: %w", err) | ||||
| 		} | ||||
|  | ||||
| 		if err = diskFile.Close(); err != nil { | ||||
| 			_ = os.Remove(emojiPath) | ||||
| 			return fmt.Errorf("unable to close custom emoji file on disk: %w", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										4
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								main.go
									
									
									
									
									
								
							| @ -47,6 +47,10 @@ func main() { | ||||
| 			log.Fatalln("Cannot create data directory", err) | ||||
| 		} | ||||
| 	} | ||||
| 	// Set up emoji directory | ||||
| 	if err := data.SetupEmojiDirectory(); err != nil { | ||||
| 		log.Fatalln("Cannot set up emoji directory", err) | ||||
| 	} | ||||
|  | ||||
| 	// Recreate the temp dir | ||||
| 	if utils.DoesFileExists(config.TempDir) { | ||||
|  | ||||
| @ -39,7 +39,7 @@ func Start() error { | ||||
| 	http.HandleFunc("/logo", controllers.GetLogo) | ||||
|  | ||||
| 	// Return a single emoji image. | ||||
| 	http.HandleFunc("/img/emoji/", controllers.GetCustomEmojiImage) | ||||
| 	http.HandleFunc(config.EmojiDir, controllers.GetCustomEmojiImage) | ||||
|  | ||||
| 	// return the logo | ||||
|  | ||||
| @ -156,6 +156,12 @@ func Start() error { | ||||
| 	// Set the following state of a follower or follow request. | ||||
| 	http.HandleFunc("/api/admin/followers/approve", middleware.RequireAdminAuth(admin.ApproveFollower)) | ||||
|  | ||||
| 	// Upload custom emoji | ||||
| 	http.HandleFunc("/api/admin/emoji/upload", middleware.RequireAdminAuth(admin.UploadCustomEmoji)) | ||||
|  | ||||
| 	// Delete custom emoji | ||||
| 	http.HandleFunc("/api/admin/emoji/delete", middleware.RequireAdminAuth(admin.DeleteCustomEmoji)) | ||||
|  | ||||
| 	// Update config values | ||||
|  | ||||
| 	// Change the current streaming key in memory | ||||
|  | ||||
| @ -2,6 +2,7 @@ package utils | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/base64" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| @ -364,3 +365,42 @@ func ShuffleStringSlice(s []string) []string { | ||||
| func IntPercentage(x, total int) int { | ||||
| 	return int(float64(x) / float64(total) * 100) | ||||
| } | ||||
|  | ||||
| // DecodeBase64Image decodes a base64 image string into a byte array, returning the extension (including dot) for the content type. | ||||
| func DecodeBase64Image(url string) (bytes []byte, extension string, err error) { | ||||
| 	s := strings.SplitN(url, ",", 2) | ||||
| 	if len(s) < 2 { | ||||
| 		err = errors.New("error splitting base64 image data") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	bytes, err = base64.StdEncoding.DecodeString(s[1]) | ||||
| 	if err != nil { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	splitHeader := strings.Split(s[0], ":") | ||||
| 	if len(splitHeader) < 2 { | ||||
| 		err = errors.New("error splitting base64 image header") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	contentType := strings.Split(splitHeader[1], ";")[0] | ||||
|  | ||||
| 	if contentType == "image/svg+xml" { | ||||
| 		extension = ".svg" | ||||
| 	} else if contentType == "image/gif" { | ||||
| 		extension = ".gif" | ||||
| 	} else if contentType == "image/png" { | ||||
| 		extension = ".png" | ||||
| 	} else if contentType == "image/jpeg" { | ||||
| 		extension = ".jpeg" | ||||
| 	} | ||||
|  | ||||
| 	if extension == "" { | ||||
| 		err = errors.New("missing or invalid contentType in base64 image") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	return bytes, extension, nil | ||||
| } | ||||
|  | ||||
| @ -143,6 +143,10 @@ export const MainLayout: FC<MainLayoutProps> = ({ children }) => { | ||||
|       label: <Link href="/admin/chat/users">Users</Link>, | ||||
|       key: 'chat-users', | ||||
|     }, | ||||
|     { | ||||
|       label: <Link href="/admin/chat/emojis">Emojis</Link>, | ||||
|       key: 'emojis', | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   const utilitiesMenu = [ | ||||
|  | ||||
| @ -18,13 +18,7 @@ import { | ||||
| } from '../../utils/input-statuses'; | ||||
| import { NEXT_PUBLIC_API_HOST } from '../../utils/apis'; | ||||
|  | ||||
| const ACCEPTED_FILE_TYPES = ['image/png', 'image/jpeg', 'image/gif']; | ||||
|  | ||||
| function getBase64(img: File | Blob, callback: (imageUrl: string | ArrayBuffer) => void) { | ||||
|   const reader = new FileReader(); | ||||
|   reader.addEventListener('load', () => callback(reader.result)); | ||||
|   reader.readAsDataURL(img); | ||||
| } | ||||
| import { ACCEPTED_IMAGE_TYPES, getBase64 } from '../../utils/images'; | ||||
|  | ||||
| export const EditLogo: FC = () => { | ||||
|   const [logoUrl, setlogoUrl] = useState(null); | ||||
| @ -53,7 +47,7 @@ export const EditLogo: FC = () => { | ||||
|  | ||||
|     // eslint-disable-next-line consistent-return | ||||
|     return new Promise<void>((res, rej) => { | ||||
|       if (!ACCEPTED_FILE_TYPES.includes(file.type)) { | ||||
|       if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { | ||||
|         const msg = `File type is not supported: ${file.type}`; | ||||
|         setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${msg}`)); | ||||
|         resetTimer = setTimeout(resetStates, RESET_TIMEOUT); | ||||
| @ -108,7 +102,7 @@ export const EditLogo: FC = () => { | ||||
|             listType="picture" | ||||
|             className="avatar-uploader" | ||||
|             showUploadList={false} | ||||
|             accept={ACCEPTED_FILE_TYPES.join(',')} | ||||
|             accept={ACCEPTED_IMAGE_TYPES.join(',')} | ||||
|             beforeUpload={beforeUpload} | ||||
|             customRequest={handleLogoUpdate} | ||||
|             disabled={loading} | ||||
|  | ||||
							
								
								
									
										183
									
								
								web/pages/admin/chat/emojis.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								web/pages/admin/chat/emojis.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,183 @@ | ||||
| import { DeleteOutlined } from '@ant-design/icons'; | ||||
| import { Button, Space, Table, Typography, Upload } from 'antd'; | ||||
| import { RcFile } from 'antd/lib/upload'; | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import FormStatusIndicator from '../../../components/config/FormStatusIndicator'; | ||||
|  | ||||
| import { DELETE_EMOJI, fetchData, UPLOAD_EMOJI } from '../../../utils/apis'; | ||||
|  | ||||
| import { ACCEPTED_IMAGE_TYPES, getBase64 } from '../../../utils/images'; | ||||
| import { | ||||
|   createInputStatus, | ||||
|   STATUS_ERROR, | ||||
|   STATUS_PROCESSING, | ||||
|   STATUS_SUCCESS, | ||||
| } from '../../../utils/input-statuses'; | ||||
| import { RESET_TIMEOUT } from '../../../utils/config-constants'; | ||||
| import { URL_CUSTOM_EMOJIS } from '../../../utils/constants'; | ||||
|  | ||||
| type CustomEmoji = { | ||||
|   name: string; | ||||
|   url: string; | ||||
| }; | ||||
|  | ||||
| const { Title, Paragraph } = Typography; | ||||
|  | ||||
| const Emoji = () => { | ||||
|   const [emojis, setEmojis] = useState<CustomEmoji[]>([]); | ||||
|  | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [submitStatus, setSubmitStatus] = useState(null); | ||||
|   const [uploadFile, setUploadFile] = useState<RcFile>(null); | ||||
|  | ||||
|   let resetTimer = null; | ||||
|   const resetStates = () => { | ||||
|     setSubmitStatus(null); | ||||
|     clearTimeout(resetTimer); | ||||
|     resetTimer = null; | ||||
|   }; | ||||
|  | ||||
|   async function getEmojis() { | ||||
|     setLoading(true); | ||||
|     try { | ||||
|       const response = await fetchData(URL_CUSTOM_EMOJIS); | ||||
|       setEmojis(response); | ||||
|     } catch (error) { | ||||
|       console.error('error fetching emojis', error); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   } | ||||
|   useEffect(() => { | ||||
|     getEmojis(); | ||||
|   }, []); | ||||
|  | ||||
|   async function handleDelete(name: string) { | ||||
|     setLoading(true); | ||||
|  | ||||
|     setSubmitStatus(createInputStatus(STATUS_PROCESSING, 'Deleting emoji...')); | ||||
|  | ||||
|     try { | ||||
|       const response = await fetchData(DELETE_EMOJI, { | ||||
|         method: 'POST', | ||||
|         data: { name }, | ||||
|       }); | ||||
|  | ||||
|       if (response instanceof Error) { | ||||
|         throw response; | ||||
|       } | ||||
|  | ||||
|       setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Emoji deleted')); | ||||
|       resetTimer = setTimeout(resetStates, RESET_TIMEOUT); | ||||
|     } catch (error) { | ||||
|       setSubmitStatus(createInputStatus(STATUS_ERROR, `${error}`)); | ||||
|       setLoading(false); | ||||
|       resetTimer = setTimeout(resetStates, RESET_TIMEOUT); | ||||
|     } | ||||
|  | ||||
|     getEmojis(); | ||||
|   } | ||||
|  | ||||
|   async function handleEmojiUpload() { | ||||
|     setLoading(true); | ||||
|     try { | ||||
|       setSubmitStatus(createInputStatus(STATUS_PROCESSING, 'Converting emoji...')); | ||||
|  | ||||
|       // eslint-disable-next-line consistent-return | ||||
|       const emojiData = await new Promise<CustomEmoji>((res, rej) => { | ||||
|         if (!ACCEPTED_IMAGE_TYPES.includes(uploadFile.type)) { | ||||
|           const msg = `File type is not supported: ${uploadFile.type}`; | ||||
|           // eslint-disable-next-line no-promise-executor-return | ||||
|           return rej(msg); | ||||
|         } | ||||
|  | ||||
|         getBase64(uploadFile, (url: string) => | ||||
|           res({ | ||||
|             name: uploadFile.name, | ||||
|             url, | ||||
|           }), | ||||
|         ); | ||||
|       }); | ||||
|  | ||||
|       setSubmitStatus(createInputStatus(STATUS_PROCESSING, 'Uploading emoji...')); | ||||
|  | ||||
|       const response = await fetchData(UPLOAD_EMOJI, { | ||||
|         method: 'POST', | ||||
|         data: { | ||||
|           name: emojiData.name, | ||||
|           data: emojiData.url, | ||||
|         }, | ||||
|       }); | ||||
|  | ||||
|       if (response instanceof Error) { | ||||
|         throw response; | ||||
|       } | ||||
|  | ||||
|       setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Emoji uploaded successfully!')); | ||||
|       getEmojis(); | ||||
|     } catch (error) { | ||||
|       setSubmitStatus(createInputStatus(STATUS_ERROR, `${error}`)); | ||||
|     } | ||||
|  | ||||
|     resetTimer = setTimeout(resetStates, RESET_TIMEOUT); | ||||
|     setLoading(false); | ||||
|   } | ||||
|  | ||||
|   const columns = [ | ||||
|     { | ||||
|       title: '', | ||||
|       key: 'delete', | ||||
|       render: (text, record) => ( | ||||
|         <Space size="middle"> | ||||
|           <Button onClick={() => handleDelete(record.name)} icon={<DeleteOutlined />} /> | ||||
|         </Space> | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|       title: 'Name', | ||||
|       key: 'name', | ||||
|       dataIndex: 'name', | ||||
|     }, | ||||
|     { | ||||
|       title: 'Emoji', | ||||
|       key: 'url', | ||||
|       render: (text, record) => ( | ||||
|         <img src={record.url} alt={record.name} style={{ maxWidth: '2vw' }} /> | ||||
|       ), | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <div> | ||||
|       <Title>Emojis</Title> | ||||
|       <Paragraph> | ||||
|         Here you can upload new custom emojis for usage in the chat. When uploading a new emoji, the | ||||
|         filename will be used as emoji name. | ||||
|       </Paragraph> | ||||
|  | ||||
|       <Table | ||||
|         rowKey={record => record.url} | ||||
|         dataSource={emojis} | ||||
|         columns={columns} | ||||
|         pagination={false} | ||||
|       /> | ||||
|       <br /> | ||||
|       <Upload | ||||
|         name="emoji" | ||||
|         listType="picture" | ||||
|         className="emoji-uploader" | ||||
|         showUploadList={false} | ||||
|         accept={ACCEPTED_IMAGE_TYPES.join(',')} | ||||
|         beforeUpload={setUploadFile} | ||||
|         customRequest={handleEmojiUpload} | ||||
|         disabled={loading} | ||||
|       > | ||||
|         <Button type="primary" disabled={loading}> | ||||
|           Upload new emoji | ||||
|         </Button> | ||||
|       </Upload> | ||||
|       <FormStatusIndicator status={submitStatus} /> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default Emoji; | ||||
| @ -64,6 +64,12 @@ export const CHAT_HISTORY = `${API_LOCATION}chat/messages`; | ||||
| // Get chat history | ||||
| export const UPDATE_CHAT_MESSGAE_VIZ = `/api/admin/chat/messagevisibility`; | ||||
|  | ||||
| // Upload a new custom emoji | ||||
| export const UPLOAD_EMOJI = `${API_LOCATION}emoji/upload`; | ||||
|  | ||||
| // Delete a custom emoji | ||||
| export const DELETE_EMOJI = `${API_LOCATION}emoji/delete`; | ||||
|  | ||||
| // Get all access tokens | ||||
| export const ACCESS_TOKENS = `${API_LOCATION}accesstokens`; | ||||
|  | ||||
|  | ||||
							
								
								
									
										7
									
								
								web/utils/images.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								web/utils/images.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | ||||
| export const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif']; | ||||
|  | ||||
| export function getBase64(img: File | Blob, callback: (imageUrl: string | ArrayBuffer) => void) { | ||||
|   const reader = new FileReader(); | ||||
|   reader.addEventListener('load', () => callback(reader.result)); | ||||
|   reader.readAsDataURL(img); | ||||
| } | ||||
		Reference in New Issue
	
	Block a user
	 Philipp
					Philipp