diff --git a/src/features/edit-identity/index.tsx b/src/features/edit-identity/index.tsx new file mode 100644 index 000000000..c5dc68ab3 --- /dev/null +++ b/src/features/edit-identity/index.tsx @@ -0,0 +1,122 @@ +import React, { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { patchMe } from 'soapbox/actions/me'; +import List, { ListItem } from 'soapbox/components/list'; +import { Button, Column, Emoji, HStack, Icon, Input, Tooltip } from 'soapbox/components/ui'; +import { useNostr } from 'soapbox/contexts/nostr-context'; +import { useNostrReq } from 'soapbox/features/nostr/hooks/useNostrReq'; +import { useAppDispatch, useInstance, useOwnAccount } from 'soapbox/hooks'; +import toast from 'soapbox/toast'; + +interface IEditIdentity { +} + +const messages = defineMessages({ + title: { id: 'settings.edit_identity', defaultMessage: 'Identity' }, + username: { id: 'edit_profile.fields.nip05_label', defaultMessage: 'Username' }, + unverified: { id: 'edit_profile.fields.nip05_unverified', defaultMessage: 'Name could not be verified and won\'t be used.' }, + success: { id: 'edit_profile.success', defaultMessage: 'Your profile has been successfully saved!' }, + error: { id: 'edit_profile.error', defaultMessage: 'Profile update failed' }, +}); + +/** EditIdentity component. */ +const EditIdentity: React.FC = () => { + const intl = useIntl(); + const instance = useInstance(); + const dispatch = useAppDispatch(); + const { account } = useOwnAccount(); + const { relay, signer } = useNostr(); + + const admin = instance.nostr?.pubkey; + const pubkey = account?.nostr?.pubkey; + const [username, setUsername] = useState(''); + + const { events: labels } = useNostrReq( + (admin && pubkey) + ? [{ kinds: [1985], authors: [admin], '#L': ['nip05'], '#p': [pubkey] }] + : [], + ); + + if (!account) return null; + + const updateNip05 = async (nip05: string): Promise => { + if (account.source?.nostr?.nip05 === nip05) return; + try { + await dispatch(patchMe({ nip05 })); + toast.success(intl.formatMessage(messages.success)); + } catch (e) { + toast.error(intl.formatMessage(messages.error)); + } + }; + + const submit = async () => { + if (!admin || !signer || !relay) return; + + const event = await signer.signEvent({ + kind: 5950, + content: '', + tags: [ + ['i', `${username}@${instance.domain}`, 'text'], + ['p', admin], + ], + created_at: Math.floor(Date.now() / 1000), + }); + + await relay.event(event); + }; + + return ( + + + {labels.map((label) => { + const identifier = label.tags.find(([name]) => name === 'l')?.[1]; + if (!identifier) return null; + + return ( + + {identifier} + {(account.source?.nostr?.nip05 === identifier && account.acct !== identifier) && ( + +
+ +
+
+ )} + + } + isSelected={account.source?.nostr?.nip05 === identifier} + onSelect={() => updateNip05(identifier)} + /> + ); + })} + setUsername(e.target.value)} />}> + + +
+
+ ); +}; + +const UsernameInput: React.FC> = (props) => { + const intl = useIntl(); + const instance = useInstance(); + + return ( + + + {instance.domain} + + )} + {...props} + /> + ); +}; + +export default EditIdentity; \ No newline at end of file diff --git a/src/features/edit-profile/index.tsx b/src/features/edit-profile/index.tsx index 03fe961aa..a01fbe816 100644 --- a/src/features/edit-profile/index.tsx +++ b/src/features/edit-profile/index.tsx @@ -325,7 +325,7 @@ const EditProfile: React.FC = () => { type='text' value={data.nip05} onChange={handleTextChange('nip05')} - placeholder={intl.formatMessage(messages.nip05Placeholder, { domain: location.host })} + placeholder={intl.formatMessage(messages.nip05Placeholder, { domain: instance.domain })} /> )} diff --git a/src/features/nostr/hooks/useNostrReq.ts b/src/features/nostr/hooks/useNostrReq.ts index 654c814f3..72e1187ac 100644 --- a/src/features/nostr/hooks/useNostrReq.ts +++ b/src/features/nostr/hooks/useNostrReq.ts @@ -1,14 +1,17 @@ -import { NostrEvent, NostrFilter } from '@soapbox/nspec'; +import { NSet, NostrEvent, NostrFilter } from '@soapbox/nspec'; import isEqual from 'lodash/isEqual'; import { useEffect, useRef, useState } from 'react'; import { useNostr } from 'soapbox/contexts/nostr-context'; +import { useForceUpdate } from 'soapbox/hooks/useForceUpdate'; /** Streams events from the relay for the given filters. */ export function useNostrReq(filters: NostrFilter[]): { events: NostrEvent[]; eose: boolean; closed: boolean } { const { relay } = useNostr(); - const [events, setEvents] = useState([]); + const nset = useRef(new NSet()); + const forceUpdate = useForceUpdate(); + const [closed, setClosed] = useState(false); const [eose, setEose] = useState(false); @@ -21,7 +24,8 @@ export function useNostrReq(filters: NostrFilter[]): { events: NostrEvent[]; eos (async () => { for await (const msg of relay.req(value, { signal })) { if (msg[0] === 'EVENT') { - setEvents((prev) => [msg[2], ...prev]); + nset.current.add(msg[2]); + forceUpdate(); } else if (msg[0] === 'EOSE') { setEose(true); } else if (msg[0] === 'CLOSED') { @@ -41,7 +45,7 @@ export function useNostrReq(filters: NostrFilter[]): { events: NostrEvent[]; eos }, [relay, value]); return { - events, + events: [...nset.current], eose, closed, }; diff --git a/src/features/settings/index.tsx b/src/features/settings/index.tsx index 434445c4e..f04f311a8 100644 --- a/src/features/settings/index.tsx +++ b/src/features/settings/index.tsx @@ -20,6 +20,7 @@ const messages = defineMessages({ configureMfa: { id: 'settings.configure_mfa', defaultMessage: 'Configure MFA' }, deleteAccount: { id: 'settings.delete_account', defaultMessage: 'Delete Account' }, editProfile: { id: 'settings.edit_profile', defaultMessage: 'Edit Profile' }, + editIdentity: { id: 'settings.edit_identity', defaultMessage: 'Identity' }, exportData: { id: 'column.export_data', defaultMessage: 'Export data' }, importData: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' }, mfaDisabled: { id: 'mfa.disabled', defaultMessage: 'Disabled' }, @@ -65,6 +66,11 @@ const Settings = () => { {displayName} + {features.nip05 && ( + + {account?.source?.nostr?.nip05} + + )} diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index fdfb51b92..b206a647b 100644 --- a/src/features/ui/index.tsx +++ b/src/features/ui/index.tsx @@ -137,6 +137,7 @@ import { ExternalLogin, LandingTimeline, BookmarkFolders, + EditIdentity, Domains, } from './util/async-components'; import GlobalHotkeys from './util/global-hotkeys'; @@ -306,6 +307,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {features.scheduledStatuses && } + {features.nip05 && } {features.exportData && } {features.importData && } {features.accountAliases && } diff --git a/src/features/ui/util/async-components.ts b/src/features/ui/util/async-components.ts index 54b7260d0..b6dff63f3 100644 --- a/src/features/ui/util/async-components.ts +++ b/src/features/ui/util/async-components.ts @@ -167,5 +167,6 @@ export const NostrLoginModal = lazy(() => import('soapbox/features/ui/components export const BookmarkFolders = lazy(() => import('soapbox/features/bookmark-folders')); export const EditBookmarkFolderModal = lazy(() => import('soapbox/features/ui/components/modals/edit-bookmark-folder-modal')); export const SelectBookmarkFolderModal = lazy(() => import('soapbox/features/ui/components/modals/select-bookmark-folder-modal')); +export const EditIdentity = lazy(() => import('soapbox/features/edit-identity')); export const Domains = lazy(() => import('soapbox/features/admin/domains')); export const EditDomainModal = lazy(() => import('soapbox/features/ui/components/modals/edit-domain-modal')); diff --git a/src/hooks/useForceUpdate.ts b/src/hooks/useForceUpdate.ts new file mode 100644 index 000000000..50a84a468 --- /dev/null +++ b/src/hooks/useForceUpdate.ts @@ -0,0 +1,11 @@ +import { useState, useCallback } from 'react'; + +export function useForceUpdate(): () => void { + const [, setState] = useState(false); + + const forceUpdate = useCallback(() => { + setState(prevState => !prevState); + }, []); + + return forceUpdate; +} diff --git a/src/locales/en.json b/src/locales/en.json index 3bd8d6740..f1189955b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -634,6 +634,7 @@ "edit_profile.fields.meta_fields_label": "Profile fields", "edit_profile.fields.nip05_label": "Username", "edit_profile.fields.nip05_placeholder": "user@{domain}", + "edit_profile.fields.nip05_unverified": "Name could not be verified and won't be used.", "edit_profile.fields.stranger_notifications_label": "Block notifications from strangers", "edit_profile.fields.website_label": "Website", "edit_profile.fields.website_placeholder": "Display a Link", @@ -1376,6 +1377,7 @@ "settings.change_password": "Change Password", "settings.configure_mfa": "Configure MFA", "settings.delete_account": "Delete Account", + "settings.edit_identity": "Identity", "settings.edit_profile": "Edit Profile", "settings.messages.label": "Allow users to start a new chat with you", "settings.mutes": "Mutes",