From 0e846784df799f2ada5ef286739aec9d97179492 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 9 Jun 2024 13:40:08 -0500 Subject: [PATCH 1/4] Rework admin approve/reject actions --- src/actions/admin.ts | 82 +++++++++++++------ src/actions/moderation.tsx | 4 +- src/components/account.tsx | 4 +- .../admin/components/unapproved-account.tsx | 13 +-- src/reducers/admin.ts | 28 +++---- 5 files changed, 83 insertions(+), 48 deletions(-) diff --git a/src/actions/admin.ts b/src/actions/admin.ts index 45b5076fb..0bfe1ed3f 100644 --- a/src/actions/admin.ts +++ b/src/actions/admin.ts @@ -39,6 +39,10 @@ const ADMIN_USERS_APPROVE_REQUEST = 'ADMIN_USERS_APPROVE_REQUEST'; const ADMIN_USERS_APPROVE_SUCCESS = 'ADMIN_USERS_APPROVE_SUCCESS'; const ADMIN_USERS_APPROVE_FAIL = 'ADMIN_USERS_APPROVE_FAIL'; +const ADMIN_USERS_REJECT_REQUEST = 'ADMIN_USERS_REJECT_REQUEST'; +const ADMIN_USERS_REJECT_SUCCESS = 'ADMIN_USERS_REJECT_SUCCESS'; +const ADMIN_USERS_REJECT_FAIL = 'ADMIN_USERS_REJECT_FAIL'; + const ADMIN_USERS_DEACTIVATE_REQUEST = 'ADMIN_USERS_DEACTIVATE_REQUEST'; const ADMIN_USERS_DEACTIVATE_SUCCESS = 'ADMIN_USERS_DEACTIVATE_SUCCESS'; const ADMIN_USERS_DEACTIVATE_FAIL = 'ADMIN_USERS_DEACTIVATE_FAIL'; @@ -309,56 +313,80 @@ const deactivateUsers = (accountIds: string[], reportId?: string) => } }; -const deleteUsers = (accountIds: string[]) => +const deleteUser = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { - const nicknames = accountIdsToAccts(getState(), accountIds); - dispatch({ type: ADMIN_USERS_DELETE_REQUEST, accountIds }); + const nicknames = accountIdsToAccts(getState(), [accountId]); + dispatch({ type: ADMIN_USERS_DELETE_REQUEST, accountId }); return api(getState) .delete('/api/v1/pleroma/admin/users', { data: { nicknames } }) .then(({ data: nicknames }) => { - dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames, accountIds }); + dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames, accountId }); }).catch(error => { - dispatch({ type: ADMIN_USERS_DELETE_FAIL, error, accountIds }); + dispatch({ type: ADMIN_USERS_DELETE_FAIL, error, accountId }); }); }; -const approveMastodonUsers = (accountIds: string[]) => +const approveMastodonUser = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => - Promise.all(accountIds.map(accountId => { - api(getState) - .post(`/api/v1/admin/accounts/${accountId}/approve`) - .then(({ data: user }) => { - dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users: [user], accountIds: [accountId] }); - }).catch(error => { - dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountIds: [accountId] }); - }); - })); + api(getState) + .post(`/api/v1/admin/accounts/${accountId}/approve`) + .then(({ data: user }) => { + dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, user, accountId }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountId }); + }); -const approvePleromaUsers = (accountIds: string[]) => +const approvePleromaUser = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { - const nicknames = accountIdsToAccts(getState(), accountIds); + const nicknames = accountIdsToAccts(getState(), [accountId]); return api(getState) .patch('/api/v1/pleroma/admin/users/approve', { nicknames }) .then(({ data: { users } }) => { - dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users, accountIds }); + dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, user: users[0], accountId }); + }).catch(error => { + dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountId }); + }); + }; + +const rejectMastodonUser = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => + api(getState) + .post(`/api/v1/admin/accounts/${accountId}/reject`) + .then(({ data: user }) => { + dispatch({ type: ADMIN_USERS_REJECT_SUCCESS, user, accountId }); }).catch(error => { - dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountIds }); + dispatch({ type: ADMIN_USERS_REJECT_FAIL, error, accountId }); }); + +const approveUser = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + + const instance = state.instance; + const features = getFeatures(instance); + + dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountId }); + + if (features.mastodonAdmin) { + return dispatch(approveMastodonUser(accountId)); + } else { + return dispatch(approvePleromaUser(accountId)); + } }; -const approveUsers = (accountIds: string[]) => +const rejectUser = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); const instance = state.instance; const features = getFeatures(instance); - dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountIds }); + dispatch({ type: ADMIN_USERS_REJECT_REQUEST, accountId }); if (features.mastodonAdmin) { - return dispatch(approveMastodonUsers(accountIds)); + return dispatch(rejectMastodonUser(accountId)); } else { - return dispatch(approvePleromaUsers(accountIds)); + return dispatch(deleteUser(accountId)); } }; @@ -562,6 +590,9 @@ export { ADMIN_USERS_APPROVE_REQUEST, ADMIN_USERS_APPROVE_SUCCESS, ADMIN_USERS_APPROVE_FAIL, + ADMIN_USERS_REJECT_REQUEST, + ADMIN_USERS_REJECT_SUCCESS, + ADMIN_USERS_REJECT_FAIL, ADMIN_USERS_DEACTIVATE_REQUEST, ADMIN_USERS_DEACTIVATE_SUCCESS, ADMIN_USERS_DEACTIVATE_FAIL, @@ -597,8 +628,9 @@ export { closeReports, fetchUsers, deactivateUsers, - deleteUsers, - approveUsers, + deleteUser, + approveUser, + rejectUser, deleteStatus, toggleStatusSensitivity, tagUsers, diff --git a/src/actions/moderation.tsx b/src/actions/moderation.tsx index 35ad6a4d7..ddee5fe1d 100644 --- a/src/actions/moderation.tsx +++ b/src/actions/moderation.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { defineMessages, IntlShape } from 'react-intl'; import { fetchAccountByUsername } from 'soapbox/actions/accounts'; -import { deactivateUsers, deleteUsers, deleteStatus, toggleStatusSensitivity } from 'soapbox/actions/admin'; +import { deactivateUsers, deleteUser, deleteStatus, toggleStatusSensitivity } from 'soapbox/actions/admin'; import { openModal } from 'soapbox/actions/modals'; import OutlineBox from 'soapbox/components/outline-box'; import { Stack, Text } from 'soapbox/components/ui'; @@ -102,7 +102,7 @@ const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () = confirm, checkbox, onConfirm: () => { - dispatch(deleteUsers([accountId])).then(() => { + dispatch(deleteUser(accountId)).then(() => { const message = intl.formatMessage(messages.userDeleted, { acct }); dispatch(fetchAccountByUsername(acct)); toast.success(message); diff --git a/src/components/account.tsx b/src/components/account.tsx index 5751b2c3a..4871079a7 100644 --- a/src/components/account.tsx +++ b/src/components/account.tsx @@ -71,6 +71,7 @@ const ProfilePopper: React.FC = ({ condition, wrapper, children }; export interface IAccount { + acct?: string; account: AccountSchema; action?: React.ReactElement; actionAlignment?: 'center' | 'top'; @@ -99,6 +100,7 @@ export interface IAccount { } const Account = ({ + acct, account, actionType, action, @@ -228,7 +230,7 @@ const Account = ({ - @{username} + @{acct ?? username} {account.pleroma?.favicon && ( diff --git a/src/features/admin/components/unapproved-account.tsx b/src/features/admin/components/unapproved-account.tsx index 503fb513e..b10d9428c 100644 --- a/src/features/admin/components/unapproved-account.tsx +++ b/src/features/admin/components/unapproved-account.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { approveUsers, deleteUsers } from 'soapbox/actions/admin'; +import { approveUser, rejectUser } from 'soapbox/actions/admin'; import { useAccount } from 'soapbox/api/hooks'; import Account from 'soapbox/components/account'; import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons'; @@ -14,18 +14,19 @@ interface IUnapprovedAccount { const UnapprovedAccount: React.FC = ({ accountId }) => { const dispatch = useAppDispatch(); - const { account } = useAccount(accountId); const adminAccount = useAppSelector(state => state.admin.users.get(accountId)); + const { account } = useAccount(adminAccount?.account || undefined); - if (!account) return null; + if (!adminAccount || !account) return null; - const handleApprove = () => dispatch(approveUsers([account.id])); - const handleReject = () => dispatch(deleteUsers([account.id])); + const handleApprove = () => dispatch(approveUser(adminAccount.id)); + const handleReject = () => dispatch(rejectUser(adminAccount.id)); return ( { - accountIds.forEach(id => { - state.update('awaitingApproval', orderedSet => orderedSet.delete(id)); - state.deleteIn(['users', id]); - }); + state.update('awaitingApproval', orderedSet => orderedSet.delete(accountId)); + state.deleteIn(['users', accountId]); }); } -function approveUsers(state: State, users: APIUser[]): State { +function approveUser(state: State, user: APIUser): State { + const normalizedUser = fixUser(user); return state.withMutations(state => { - users.forEach(user => { - const normalizedUser = fixUser(user); - state.update('awaitingApproval', orderedSet => orderedSet.delete(user.id)); - state.setIn(['users', user.id], normalizedUser); - }); + state.update('awaitingApproval', orderedSet => orderedSet.delete(user.id)); + state.setIn(['users', user.id], normalizedUser); }); } @@ -207,11 +205,13 @@ export default function admin(state: State = ReducerRecord(), action: AnyAction) return importUsers(state, action.users, action.filters, action.page); case ADMIN_USERS_DELETE_REQUEST: case ADMIN_USERS_DELETE_SUCCESS: - return deleteUsers(state, action.accountIds); + case ADMIN_USERS_REJECT_REQUEST: + case ADMIN_USERS_REJECT_SUCCESS: + return deleteUser(state, action.accountId); case ADMIN_USERS_APPROVE_REQUEST: - return state.update('awaitingApproval', set => set.subtract(action.accountIds)); + return state.update('awaitingApproval', set => set.remove(action.accountId)); case ADMIN_USERS_APPROVE_SUCCESS: - return approveUsers(state, action.users); + return approveUser(state, action.user); default: return state; } From def7cc4d14508f99c1195fe2cb9a1f493a14cf45 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 9 Jun 2024 17:00:41 -0500 Subject: [PATCH 2/4] Support ditto:name_grant notification type --- src/features/edit-profile/index.tsx | 4 +- .../notifications/components/notification.tsx | 52 ++++++++++++++----- src/normalizers/notification.ts | 1 + src/schemas/notification.ts | 7 ++- src/utils/notification.ts | 1 + 5 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/features/edit-profile/index.tsx b/src/features/edit-profile/index.tsx index f185f9e63..971084931 100644 --- a/src/features/edit-profile/index.tsx +++ b/src/features/edit-profile/index.tsx @@ -129,8 +129,8 @@ interface AccountCredentials { birthday?: string; /** Nostr NIP-05 identifier. */ nip05?: string; - /** - * Lightning address. + /** + * Lightning address. * https://github.com/lnurl/luds/blob/luds/16.md */ lud16?: string; diff --git a/src/features/notifications/components/notification.tsx b/src/features/notifications/components/notification.tsx index 7a05a7d23..fca1e79e6 100644 --- a/src/features/notifications/components/notification.tsx +++ b/src/features/notifications/components/notification.tsx @@ -1,19 +1,21 @@ import React, { useCallback } from 'react'; -import { defineMessages, useIntl, FormattedMessage, IntlShape, MessageDescriptor, defineMessage } from 'react-intl'; +import { defineMessages, useIntl, IntlShape, MessageDescriptor, defineMessage, FormattedMessage } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; import { mentionCompose } from 'soapbox/actions/compose'; import { reblog, favourite, unreblog, unfavourite } from 'soapbox/actions/interactions'; +import { patchMe } from 'soapbox/actions/me'; import { openModal } from 'soapbox/actions/modals'; import { getSettings } from 'soapbox/actions/settings'; import { hideStatus, revealStatus } from 'soapbox/actions/statuses'; import Icon from 'soapbox/components/icon'; -import { HStack, Text, Emoji } from 'soapbox/components/ui'; +import { HStack, Text, Emoji, Button, Stack } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account-container'; import StatusContainer from 'soapbox/containers/status-container'; import { HotKeys } from 'soapbox/features/ui/components/hotkeys'; import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks'; import { makeGetNotification } from 'soapbox/selectors'; +import toast from 'soapbox/toast'; import { NotificationType, validType } from 'soapbox/utils/notification'; import type { ScrollPosition } from 'soapbox/components/status'; @@ -56,6 +58,7 @@ const icons: Record = { 'pleroma:event_reminder': require('@tabler/icons/outline/calendar-time.svg'), 'pleroma:participation_request': require('@tabler/icons/outline/calendar-event.svg'), 'pleroma:participation_accepted': require('@tabler/icons/outline/calendar-event.svg'), + 'ditto:name_grant': require('@tabler/icons/outline/user-check.svg'), }; const nameMessage = defineMessage({ @@ -63,7 +66,7 @@ const nameMessage = defineMessage({ defaultMessage: '{link}{others}', }); -const messages: Record = defineMessages({ +const notificationMessages: Record = defineMessages({ follow: { id: 'notification.follow', defaultMessage: '{name} followed you', @@ -132,29 +135,32 @@ const messages: Record = defineMessages({ id: 'notification.pleroma:participation_accepted', defaultMessage: 'You were accepted to join the event', }, + 'ditto:name_grant': { + id: 'notification.ditto:name_grant', + defaultMessage: 'You were granted the name {acct}', + }, +}); + +const messages = defineMessages({ + updateNameSuccess: { id: 'notification.update_name_success', defaultMessage: 'Name updated successfully' }, }); const buildMessage = ( intl: IntlShape, type: NotificationType, account: AccountEntity, - totalCount: number | null, + acct: string | undefined, targetName: string, instanceTitle: string, ): React.ReactNode => { const link = buildLink(account); const name = intl.formatMessage(nameMessage, { link, - others: totalCount && totalCount > 0 ? ( - - ) : '', + others: '', }); - return intl.formatMessage(messages[type], { + return intl.formatMessage(notificationMessages[type], { + acct, name, targetName, instance: instanceTitle, @@ -274,6 +280,11 @@ const Notification: React.FC = (props) => { } }; + const updateName = async (name: string) => { + await dispatch(patchMe({ nip05: name })); + toast.success(messages.updateNameSuccess); + }; + const renderIcon = (): React.ReactNode => { if (type === 'pleroma:emoji_reaction' && notification.emoji) { return ( @@ -349,19 +360,32 @@ const Notification: React.FC = (props) => { showGroup={false} /> ) : null; + case 'ditto:name_grant': + return ( + + + + ); default: return null; } }; + const acct = notification.name; const targetName = notification.target && typeof notification.target === 'object' ? notification.target.acct : ''; - const message: React.ReactNode = validType(type) && account && typeof account === 'object' ? buildMessage(intl, type, account, notification.total_count, targetName, instance.title) : null; + const message: React.ReactNode = validType(type) && account && typeof account === 'object' ? buildMessage(intl, type, account, acct, targetName, instance.title) : null; const ariaLabel = validType(type) ? ( notificationForScreenReader( intl, - intl.formatMessage(messages[type], { + intl.formatMessage(notificationMessages[type], { + acct, name: account && typeof account === 'object' ? account.acct : '', targetName, }), diff --git a/src/normalizers/notification.ts b/src/normalizers/notification.ts index 45eb93fb3..1ee8f439a 100644 --- a/src/normalizers/notification.ts +++ b/src/normalizers/notification.ts @@ -19,6 +19,7 @@ export const NotificationRecord = ImmutableRecord({ emoji: null as string | null, // pleroma:emoji_reaction emoji_url: null as string | null, // pleroma:emoji_reaction id: '', + name: '', // ditto:name_grant status: null as EmbeddedEntity, target: null as EmbeddedEntity, // move type: '', diff --git a/src/schemas/notification.ts b/src/schemas/notification.ts index 3c77de6bf..e468c4e2b 100644 --- a/src/schemas/notification.ts +++ b/src/schemas/notification.ts @@ -10,7 +10,6 @@ const baseNotificationSchema = z.object({ created_at: z.string().datetime().catch(new Date().toUTCString()), id: z.string(), type: z.string(), - total_count: z.number().optional().catch(undefined), // TruthSocial }); const mentionNotificationSchema = baseNotificationSchema.extend({ @@ -82,6 +81,11 @@ const participationAcceptedNotificationSchema = baseNotificationSchema.extend({ status: statusSchema, }); +const nameGrantNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('ditto:name_grant'), + name: z.string(), +}); + const notificationSchema = z.discriminatedUnion('type', [ mentionNotificationSchema, statusNotificationSchema, @@ -97,6 +101,7 @@ const notificationSchema = z.discriminatedUnion('type', [ eventReminderNotificationSchema, participationRequestNotificationSchema, participationAcceptedNotificationSchema, + nameGrantNotificationSchema, ]); type Notification = z.infer; diff --git a/src/utils/notification.ts b/src/utils/notification.ts index fdd8e25fe..d18e1d22a 100644 --- a/src/utils/notification.ts +++ b/src/utils/notification.ts @@ -17,6 +17,7 @@ const NOTIFICATION_TYPES = [ 'pleroma:event_reminder', 'pleroma:participation_request', 'pleroma:participation_accepted', + 'ditto:name_grant', ] as const; /** Notification types to exclude from the "All" filter by default. */ From 4927a321dfff2b995fcf89432a816116337b8b76 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 9 Jun 2024 18:34:40 -0500 Subject: [PATCH 3/4] Rework identity request to match the new API --- src/features/edit-identity/index.tsx | 194 ++++++++++++++++++--------- src/locales/en.json | 4 +- src/schemas/admin-account.ts | 27 ++++ 3 files changed, 164 insertions(+), 61 deletions(-) create mode 100644 src/schemas/admin-account.ts diff --git a/src/features/edit-identity/index.tsx b/src/features/edit-identity/index.tsx index 2d65bd205..0b96ed3e3 100644 --- a/src/features/edit-identity/index.tsx +++ b/src/features/edit-identity/index.tsx @@ -1,12 +1,12 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; import React, { useState } from 'react'; -import { defineMessages, useIntl } from 'react-intl'; +import { FormattedMessage, 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 { Button, CardHeader, CardTitle, Column, Emoji, Form, HStack, Icon, Input, Textarea, Tooltip } from 'soapbox/components/ui'; +import { useApi, useAppDispatch, useInstance, useOwnAccount } from 'soapbox/hooks'; +import { adminAccountSchema } from 'soapbox/schemas/admin-account'; import toast from 'soapbox/toast'; interface IEditIdentity { @@ -18,6 +18,7 @@ const messages = defineMessages({ 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' }, + placeholder: { id: 'edit_identity.reason_placeholder', defaultMessage: 'Why do you want this name?' }, }); /** EditIdentity component. */ @@ -26,77 +27,111 @@ const EditIdentity: React.FC = () => { const instance = useInstance(); const dispatch = useAppDispatch(); const { account } = useOwnAccount(); - const { relay, signer } = useNostr(); + const { mutate } = useRequestName(); - const admin = instance.nostr?.pubkey; - const pubkey = account?.nostr?.pubkey; - const [username, setUsername] = useState(''); + const { data: approvedNames } = useNames(); + const { data: pendingNames } = usePendingNames(); - const { events: labels } = useNostrReq( - (admin && pubkey) - ? [{ kinds: [1985], authors: [admin], '#L': ['nip05'], '#p': [pubkey] }] - : [], - ); + const [username, setUsername] = useState(''); + const [reason, setReason] = useState(''); if (!account) return null; - const updateNip05 = async (nip05: string): Promise => { - if (account.source?.nostr?.nip05 === nip05) return; + const updateName = async (name: string): Promise => { + if (account.source?.nostr?.nip05 === name) return; try { - await dispatch(patchMe({ nip05 })); + await dispatch(patchMe({ nip05: name })); 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); + const submit = () => { + const name = `${username}@${instance.domain}`; + mutate({ name, reason }); }; 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)} />}> - - - +
+
+ setUsername(e.target.value)} /> +