import _ from 'lodash' import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools' import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' import { FixedSizeList as List } from 'react-window' import { v4 as uuidv4 } from 'uuid' import { T_TAG_VALUE } from '../constants' import { useAppSelector, useGames, useNDKContext } from '../hooks' import { appRoutes, getModPageRoute } from '../routes' import '../styles/styles.css' import { Categories, DownloadUrl, ModDetails, ModFormState } from '../types' import { capitalizeEachWord, getCategories, initializeFormState, isReachable, isValidImageUrl, isValidUrl, log, LogType, now } from '../utils' import { CheckboxField, InputError, InputField } from './Inputs' import { LoadingSpinner } from './LoadingSpinner' import { NDKEvent } from '@nostr-dev-kit/ndk' import { OriginalAuthor } from './OriginalAuthor' interface FormErrors { game?: string title?: string body?: string featuredImageUrl?: string summary?: string nsfw?: string screenshotsUrls?: string[] tags?: string downloadUrls?: string[] author?: string originalAuthor?: string } interface GameOption { value: string label: string } type ModFormProps = { existingModData?: ModDetails } export const ModForm = ({ existingModData }: ModFormProps) => { const location = useLocation() const navigate = useNavigate() const { ndk, publish } = useNDKContext() const games = useGames() const userState = useAppSelector((state) => state.user) const [isPublishing, setIsPublishing] = useState(false) const [gameOptions, setGameOptions] = useState([]) const [formState, setFormState] = useState( initializeFormState() ) const [formErrors, setFormErrors] = useState({}) useEffect(() => { if (location.pathname === appRoutes.submitMod) { setFormState(initializeFormState()) } }, [location.pathname]) // Only trigger when the pathname changes to submit-mod useEffect(() => { if (existingModData) { setFormState(initializeFormState(existingModData)) } }, [existingModData]) useEffect(() => { const options = games.map((game) => ({ label: game['Game Name'], value: game['Game Name'] })) setGameOptions(options) }, [games]) const handleInputChange = useCallback((name: string, value: string) => { setFormState((prevState) => ({ ...prevState, [name]: value })) }, []) const handleCheckboxChange = useCallback( (e: React.ChangeEvent) => { const { name, checked } = e.target setFormState((prevState) => ({ ...prevState, [name]: checked })) }, [] ) const addScreenshotUrl = useCallback(() => { setFormState((prevState) => ({ ...prevState, screenshotsUrls: [...prevState.screenshotsUrls, ''] })) }, []) const removeScreenshotUrl = useCallback((index: number) => { setFormState((prevState) => ({ ...prevState, screenshotsUrls: prevState.screenshotsUrls.filter((_, i) => i !== index) })) }, []) const handleScreenshotUrlChange = useCallback( (index: number, value: string) => { setFormState((prevState) => ({ ...prevState, screenshotsUrls: [ ...prevState.screenshotsUrls.map((url, i) => { if (index === i) return value return url }) ] })) }, [] ) const addDownloadUrl = useCallback(() => { setFormState((prevState) => ({ ...prevState, downloadUrls: [ ...prevState.downloadUrls, { url: '', hash: '', signatureKey: '', malwareScanLink: '', modVersion: '', customNote: '' } ] })) }, []) const removeDownloadUrl = useCallback((index: number) => { setFormState((prevState) => ({ ...prevState, downloadUrls: prevState.downloadUrls.filter((_, i) => i !== index) })) }, []) const handleDownloadUrlChange = useCallback( (index: number, field: keyof DownloadUrl, value: string) => { setFormState((prevState) => ({ ...prevState, downloadUrls: [ ...prevState.downloadUrls.map((url, i) => { if (index === i) url[field] = value return url }) ] })) }, [] ) const handlePublish = async () => { setIsPublishing(true) let hexPubkey: string if (userState.auth && userState.user?.pubkey) { hexPubkey = userState.user.pubkey as string } else { hexPubkey = (await window.nostr?.getPublicKey()) as string } if (!hexPubkey) { toast.error('Could not get pubkey') return } if (!(await validateState())) { setIsPublishing(false) return } const uuid = formState.dTag || uuidv4() const currentTimeStamp = now() const aTag = formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}` const tags = [ ['d', uuid], ['a', aTag], ['r', formState.rTag], ['t', T_TAG_VALUE], [ 'published_at', existingModData ? existingModData.published_at.toString() : currentTimeStamp.toString() ], ['game', formState.game], ['title', formState.title], ['featuredImageUrl', formState.featuredImageUrl], ['summary', formState.summary], ['nsfw', formState.nsfw.toString()], ['repost', formState.repost.toString()], ['screenshotsUrls', ...formState.screenshotsUrls], ['tags', ...formState.tags.split(',')], [ 'downloadUrls', ...formState.downloadUrls.map((downloadUrl) => JSON.stringify(downloadUrl) ) ] ] if (formState.repost && formState.originalAuthor) { tags.push(['originalAuthor', formState.originalAuthor]) } // Prepend com.degmods to avoid leaking categories to 3rd party client's search // Add heirarchical namespaces labels if (formState.LTags.length > 0) { for (let i = 0; i < formState.LTags.length; i++) { tags.push(['L', `com.degmods:${formState.LTags[i]}`]) } } // Add category labels if (formState.lTags.length > 0) { for (let i = 0; i < formState.lTags.length; i++) { tags.push(['l', `com.degmods:${formState.lTags[i]}`]) } } const unsignedEvent: UnsignedEvent = { kind: kinds.ClassifiedListing, created_at: currentTimeStamp, pubkey: hexPubkey, content: formState.body, tags } const signedEvent = await window.nostr ?.signEvent(unsignedEvent) .then((event) => event as Event) .catch((err) => { toast.error('Failed to sign the event!') log(true, LogType.Error, 'Failed to sign the event!', err) return null }) if (!signedEvent) { setIsPublishing(false) return } const ndkEvent = new NDKEvent(ndk, signedEvent) const publishedOnRelays = await publish(ndkEvent) // Handle cases where publishing failed or succeeded if (publishedOnRelays.length === 0) { toast.error('Failed to publish event on any relay') } else { toast.success( `Event published successfully to the following relays\n\n${publishedOnRelays.join( '\n' )}` ) const naddr = nip19.naddrEncode({ identifier: aTag, pubkey: signedEvent.pubkey, kind: signedEvent.kind, relays: publishedOnRelays }) navigate(getModPageRoute(naddr)) } setIsPublishing(false) } const validateState = async (): Promise => { const errors: FormErrors = {} if (formState.game === '') { errors.game = 'Game field can not be empty' } if (formState.title === '') { errors.title = 'Title field can not be empty' } if (formState.body === '') { errors.body = 'Body field can not be empty' } if (formState.featuredImageUrl === '') { errors.featuredImageUrl = 'FeaturedImageUrl field can not be empty' } else if ( !isValidImageUrl(formState.featuredImageUrl) || !(await isReachable(formState.featuredImageUrl)) ) { errors.featuredImageUrl = 'FeaturedImageUrl must be a valid and reachable image URL' } if (formState.summary === '') { errors.summary = 'Summary field can not be empty' } if (formState.screenshotsUrls.length === 0) { errors.screenshotsUrls = ['Required at least one screenshot url'] } else { for (let i = 0; i < formState.screenshotsUrls.length; i++) { const url = formState.screenshotsUrls[i] if ( !isValidUrl(url) || !isValidImageUrl(url) || !(await isReachable(url)) ) { if (!errors.screenshotsUrls) errors.screenshotsUrls = Array(formState.screenshotsUrls.length) errors.screenshotsUrls![i] = 'All screenshot URLs must be valid and reachable image URLs' } } } if ( formState.repost && (!formState.originalAuthor || formState.originalAuthor === '') ) { errors.originalAuthor = 'Original author field can not be empty' } if (formState.tags === '') { errors.tags = 'Tags field can not be empty' } if (formState.downloadUrls.length === 0) { errors.downloadUrls = ['Required at least one download url'] } else { for (let i = 0; i < formState.downloadUrls.length; i++) { const downloadUrl = formState.downloadUrls[i] if (!isValidUrl(downloadUrl.url)) { if (!errors.downloadUrls) errors.downloadUrls = Array(formState.downloadUrls.length) errors.downloadUrls![i] = 'Download url must be valid and reachable' } } } setFormErrors(errors) return Object.keys(errors).length === 0 } return ( <> {isPublishing && } {formState.repost && ( <> Created by:{' '} {} } type='text' placeholder="Original author's name, npub or nprofile" name='originalAuthor' value={formState.originalAuthor || ''} error={formErrors.originalAuthor || ''} onChange={handleInputChange} /> )}

We recommend to upload images to https://nostr.build/

{formState.screenshotsUrls.map((url, index) => ( {formErrors.screenshotsUrls && formErrors.screenshotsUrls[index] && ( )} ))} {formState.screenshotsUrls.length === 0 && formErrors.screenshotsUrls && formErrors.screenshotsUrls[0] && ( )}

You can upload your game mod to Github, as an example, and keep updating it there (another option is catbox.moe). Also, it's advisable that you hash your package as well with your nostr public key.

{formState.downloadUrls.map((download, index) => ( {formErrors.downloadUrls && formErrors.downloadUrls[index] && ( )} ))} {formState.downloadUrls.length === 0 && formErrors.downloadUrls && formErrors.downloadUrls[0] && ( )}
) } type DownloadUrlFieldsProps = { index: number url: string hash: string signatureKey: string malwareScanLink: string modVersion: string customNote: string onUrlChange: (index: number, field: keyof DownloadUrl, value: string) => void onRemove: (index: number) => void } const DownloadUrlFields = React.memo( ({ index, url, hash, signatureKey, malwareScanLink, modVersion, customNote, onUrlChange, onRemove }: DownloadUrlFieldsProps) => { const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target onUrlChange(index, name as keyof DownloadUrl, value) } return (
) } ) type ScreenshotUrlFieldsProps = { index: number url: string onUrlChange: (index: number, value: string) => void onRemove: (index: number) => void } const ScreenshotUrlFields = React.memo( ({ index, url, onUrlChange, onRemove }: ScreenshotUrlFieldsProps) => { const handleChange = (e: React.ChangeEvent) => { onUrlChange(index, e.target.value) } return (
) } ) type GameDropdownProps = { options: GameOption[] selected: string error?: string onChange: (name: string, value: string) => void } const GameDropdown = ({ options, selected, error, onChange }: GameDropdownProps) => { const [searchTerm, setSearchTerm] = useState('') const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('') const inputRef = useRef(null) const isOptionClicked = useRef(false) const handleSearchChange = useCallback((value: string) => { setSearchTerm(value) _.debounce(() => { setDebouncedSearchTerm(value) }, 300)() }, []) const filteredOptions = useMemo(() => { if (debouncedSearchTerm === '') return [] else { return options.filter((option) => option.label.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ) } }, [debouncedSearchTerm, options]) return (

Can't find the game you're looking for? You can temporarily publish the mod under '(Unlisted Game)' and later edit it with the proper game name once we add it.

handleSearchChange(e.target.value)} onBlur={() => { if (!isOptionClicked.current) { setSearchTerm('') } isOptionClicked.current = false }} />
{({ index, style }) => (
(isOptionClicked.current = true)} onClick={() => { onChange('game', filteredOptions[index].value) setSearchTerm('') if (inputRef.current) { inputRef.current.blur() } }} > {filteredOptions[index].label}
)}
{error && }

Note: Please mention the game name in the body text of your mod post (e.g., 'This is a mod for Game Name') so we know what to look for and add.

) } interface CategoryAutocompleteProps { lTags: string[] LTags: string[] setFormState: (value: React.SetStateAction) => void } export const CategoryAutocomplete = ({ lTags, LTags, setFormState }: CategoryAutocompleteProps) => { const flattenedCategories = getCategories() const initialCategories = lTags.map((name) => { const existingCategory = flattenedCategories.find( (cat) => cat.name === name ) if (existingCategory) { return existingCategory } else { return { name, hierarchy: name, l: [name] } } }) const [selectedCategories, setSelectedCategories] = useState(initialCategories) const [inputValue, setInputValue] = useState('') const [filteredOptions, setFilteredOptions] = useState() useEffect(() => { const newFilteredOptions = flattenedCategories.filter((option) => option.hierarchy.toLowerCase().includes(inputValue.toLowerCase()) ) setFilteredOptions(newFilteredOptions) // eslint-disable-next-line react-hooks/exhaustive-deps }, [inputValue]) const getSelectedCategories = (cats: Categories[]) => { const uniqueValues = new Set( cats.reduce((prev, cat) => [...prev, ...cat.l], []) ) const concatenatedValue = Array.from(uniqueValues) return concatenatedValue } const getSelectedHeirarchy = (cats: Categories[]) => { const heirarchies = cats.reduce( (prev, cat) => [...prev, cat.hierarchy.replace(/ > /g, ':')], [] ) const concatenatedValue = Array.from(heirarchies) return concatenatedValue } const handleReset = () => { setSelectedCategories([]) setInputValue('') } const handleRemove = (option: Categories) => { setSelectedCategories( selectedCategories.filter((cat) => cat.hierarchy !== option.hierarchy) ) } const handleSelect = (option: Categories) => { if (!selectedCategories.some((cat) => cat.hierarchy === option.hierarchy)) { setSelectedCategories([...selectedCategories, option]) } setInputValue('') } const handleInputChange = (e: React.ChangeEvent) => { setInputValue(e.target.value) } const handleAddNew = () => { if (inputValue) { const newOption: Categories = { name: inputValue, hierarchy: inputValue, l: [inputValue] } setSelectedCategories([...selectedCategories, newOption]) setInputValue('') } } useEffect(() => { setFormState((prevState) => ({ ...prevState, ['lTags']: getSelectedCategories(selectedCategories), ['LTags']: getSelectedHeirarchy(selectedCategories) })) }, [selectedCategories, setFormState]) return (

You can select multiple categories

{filteredOptions && filteredOptions.length > 0 ? ( {({ index, style }) => (
handleSelect(filteredOptions[index])} > {capitalizeEachWord(filteredOptions[index].hierarchy)} {selectedCategories.some( (cat) => cat.hierarchy === filteredOptions[index].hierarchy ) && ( )}
)}
) : ( {({ index, style }) => (
{inputValue && !filteredOptions?.find( (option) => option.hierarchy.toLowerCase() === inputValue.toLowerCase() ) ? ( <> Add "{inputValue}" ) : ( <>No matches )}
)}
)}
{LTags.length > 0 && (
{LTags.map((hierarchy) => { const heirarchicalCategories = hierarchy.split(`:`) const categories = heirarchicalCategories .map((c: string) => (

{capitalizeEachWord(c)}

)) .reduce((prev, curr) => [ prev,

>

, curr ]) return (
{categories}
) })}
)}
) }