@@ -8,7 +8,10 @@ import {
88 useRemoveCustomEmojiMutation ,
99 useSetCustomEmojiMutation ,
1010} from "@/features/custom-emoji/hooks" ;
11- import { normalizeShortcode } from "@/shared/api/customEmoji" ;
11+ import {
12+ normalizeShortcode ,
13+ suggestShortcodeFromFilename ,
14+ } from "@/shared/api/customEmoji" ;
1215import { pickAndUploadMedia } from "@/shared/api/tauri" ;
1316import { rewriteRelayUrl } from "@/shared/lib/mediaUrl" ;
1417import { Button } from "@/shared/ui/button" ;
@@ -31,36 +34,74 @@ export function CustomEmojiSettingsCard() {
3134 const removeEmoji = useRemoveCustomEmojiMutation ( ) ;
3235
3336 const [ name , setName ] = React . useState ( "" ) ;
37+ const [ pendingUpload , setPendingUpload ] = React . useState < {
38+ url : string ;
39+ filename : string | null ;
40+ } | null > ( null ) ;
3441 const [ isUploading , setIsUploading ] = React . useState ( false ) ;
3542
3643 const normalized = normalizeShortcode ( name ) ;
3744 const nameInvalid = name . trim ( ) . length > 0 && normalized === null ;
3845 // "Replace" only applies to MY set — that's the set the upload will rewrite.
3946 const ownDuplicate =
4047 normalized !== null && own . some ( ( e ) => e . shortcode === normalized ) ;
41- const canSubmit = normalized !== null && ! isUploading && ! setEmoji . isPending ;
48+ const canSubmit =
49+ pendingUpload !== null &&
50+ normalized !== null &&
51+ ! isUploading &&
52+ ! setEmoji . isPending ;
4253
43- const handleAdd = React . useCallback ( async ( ) => {
44- if ( normalized === null ) return ;
54+ const handleUpload = React . useCallback ( async ( ) => {
4555 setIsUploading ( true ) ;
4656 try {
4757 const blobs = await pickAndUploadMedia ( ) ;
48- const url = blobs [ 0 ] ?. url ;
49- if ( ! url ) {
50- // User cancelled the picker, or nothing uploaded.
58+ const blob = blobs [ 0 ] ;
59+ if ( ! blob ?. url ) {
60+ return ;
61+ }
62+ if ( ! blob . type . startsWith ( "image/" ) ) {
63+ toast . error ( "Choose an image file for custom emoji." ) ;
5164 return ;
5265 }
53- const stored = await setEmoji . mutateAsync ( { shortcode : normalized , url } ) ;
66+ setPendingUpload ( { url : blob . url , filename : blob . filename ?? null } ) ;
67+ const suggested = blob . filename
68+ ? suggestShortcodeFromFilename ( blob . filename )
69+ : null ;
70+ if ( suggested && name . trim ( ) . length === 0 ) {
71+ setName ( suggested ) ;
72+ }
73+ } catch ( error ) {
74+ toast . error (
75+ error instanceof Error
76+ ? error . message
77+ : "Failed to upload emoji image." ,
78+ ) ;
79+ } finally {
80+ setIsUploading ( false ) ;
81+ }
82+ } , [ name ] ) ;
83+
84+ const handleAdd = React . useCallback ( async ( ) => {
85+ if ( normalized === null || pendingUpload === null ) return ;
86+ try {
87+ const stored = await setEmoji . mutateAsync ( {
88+ shortcode : normalized ,
89+ url : pendingUpload . url ,
90+ } ) ;
5491 setName ( "" ) ;
92+ setPendingUpload ( null ) ;
5593 toast . success ( `Added :${ stored } :` ) ;
5694 } catch ( error ) {
5795 toast . error (
5896 error instanceof Error ? error . message : "Failed to add emoji." ,
5997 ) ;
60- } finally {
61- setIsUploading ( false ) ;
6298 }
63- } , [ normalized , setEmoji ] ) ;
99+ } , [ normalized , pendingUpload , setEmoji ] ) ;
100+
101+ const handleReset = React . useCallback ( ( ) => {
102+ setName ( "" ) ;
103+ setPendingUpload ( null ) ;
104+ } , [ ] ) ;
64105
65106 const handleRemove = React . useCallback (
66107 async ( shortcode : string ) => {
@@ -91,49 +132,117 @@ export function CustomEmojiSettingsCard() {
91132 </ div >
92133
93134 < form
94- className = "flex items-end gap-2 "
135+ className = "max-w-2xl space-y-4 "
95136 onSubmit = { ( event ) => {
96137 event . preventDefault ( ) ;
97138 if ( canSubmit ) void handleAdd ( ) ;
98139 } }
99140 >
100- < div className = "min-w-0 flex-1 space-y-1.5" >
101- < label className = "text-sm font-medium" htmlFor = "custom-emoji-name" >
102- Name
103- </ label >
104- < div className = "flex items-center gap-1" >
105- < span className = "text-muted-foreground" > :</ span >
141+ < div className = "space-y-3" >
142+ < div >
143+ < h4 className = "text-sm font-semibold" > 1. Upload an image</ h4 >
144+ < p className = "text-sm text-muted-foreground" >
145+ Square images work best. GIF, PNG, JPEG, and WebP files are
146+ supported.
147+ </ p >
148+ </ div >
149+ < div className = "flex flex-wrap items-center gap-4" >
150+ < div className = "flex h-16 w-16 shrink-0 items-center justify-center rounded-md border bg-background" >
151+ { pendingUpload ? (
152+ < img
153+ alt = "Selected custom emoji preview"
154+ src = { rewriteRelayUrl ( pendingUpload . url ) }
155+ className = "h-14 w-14 object-contain"
156+ draggable = { false }
157+ />
158+ ) : (
159+ < ImagePlus className = "h-6 w-6 text-muted-foreground" />
160+ ) }
161+ </ div >
162+ < div className = "min-w-0 flex-1 space-y-2" >
163+ < p className = "truncate text-sm text-muted-foreground" >
164+ { pendingUpload ?. filename ?? "No image selected" }
165+ </ p >
166+ < Button
167+ type = "button"
168+ data-testid = "custom-emoji-upload"
169+ onClick = { ( ) => void handleUpload ( ) }
170+ disabled = { isUploading || setEmoji . isPending }
171+ variant = "outline"
172+ >
173+ { isUploading
174+ ? "Uploading…"
175+ : pendingUpload
176+ ? "Choose different image"
177+ : "Upload image" }
178+ </ Button >
179+ </ div >
180+ </ div >
181+ </ div >
182+
183+ < div className = "space-y-3 border-t pt-4" >
184+ < div >
185+ < h4 className = "text-sm font-semibold" > 2. Give it a name</ h4 >
186+ < p className = "text-sm text-muted-foreground" >
187+ This is what you’ll type to add this emoji to messages and
188+ reactions.
189+ </ p >
190+ </ div >
191+ < div className = "relative" >
192+ < span className = "absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" >
193+ :
194+ </ span >
106195 < Input
107196 id = "custom-emoji-name"
108197 data-testid = "custom-emoji-name-input"
109198 autoCapitalize = "none"
110199 autoCorrect = "off"
200+ className = "px-6"
111201 placeholder = "party-parrot"
112202 spellCheck = { false }
113203 value = { name }
114204 onChange = { ( event ) => setName ( event . target . value ) }
115205 />
116- < span className = "text-muted-foreground" > :</ span >
206+ < span className = "absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground" >
207+ :
208+ </ span >
117209 </ div >
210+ { nameInvalid ? (
211+ < p className = "text-sm text-destructive" >
212+ Use only letters, numbers, hyphen, or underscore.
213+ </ p >
214+ ) : pendingUpload === null ? (
215+ < p className = "text-sm text-muted-foreground" >
216+ Choose an image first; Sprout will suggest a name from the
217+ filename.
218+ </ p >
219+ ) : ownDuplicate ? (
220+ < p className = "text-sm text-muted-foreground" >
221+ You already have :{ normalized } : — saving will replace its image.
222+ </ p >
223+ ) : null }
224+ </ div >
225+
226+ < div className = "flex justify-end gap-2 border-t pt-4" >
227+ < Button
228+ type = "button"
229+ variant = "outline"
230+ onClick = { handleReset }
231+ disabled = {
232+ setEmoji . isPending || ( name . length === 0 && ! pendingUpload )
233+ }
234+ >
235+ Clear
236+ </ Button >
237+ < Button
238+ type = "submit"
239+ data-testid = "custom-emoji-add"
240+ disabled = { ! canSubmit }
241+ >
242+ { setEmoji . isPending ? "Saving…" : "Save emoji" }
243+ </ Button >
118244 </ div >
119- < Button
120- type = "submit"
121- data-testid = "custom-emoji-add"
122- disabled = { ! canSubmit }
123- >
124- < ImagePlus className = "mr-2 h-4 w-4" />
125- { isUploading ? "Uploading…" : "Upload image" }
126- </ Button >
127245 </ form >
128- { nameInvalid ? (
129- < p className = "text-sm text-destructive" >
130- Use only letters, numbers, hyphen, or underscore.
131- </ p >
132- ) : ownDuplicate ? (
133- < p className = "text-sm text-muted-foreground" >
134- You already have :{ normalized } : — uploading will replace its image.
135- </ p >
136- ) : null }
137246
138247 < div className = "space-y-3" data-testid = "custom-emoji-mine" >
139248 < h3 className = "text-sm font-medium" >
0 commit comments