kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge branch 'req-identity' into 'main'
Nostr identity request See merge request soapbox-pub/soapbox!3061environments/review-main-yi2y9f/deployments/4692
commit
a09327abeb
|
@ -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';
|
||||
|
@ -266,6 +270,14 @@ const fetchUsers = (filters: string[] = [], page = 1, query?: string | null, pag
|
|||
}
|
||||
};
|
||||
|
||||
const revokeName = (accountId: string, reportId?: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) =>
|
||||
api(getState)
|
||||
.post(`/api/v1/admin/accounts/${accountId}/action`, {
|
||||
type: 'revoke_name',
|
||||
report_id: reportId,
|
||||
});
|
||||
|
||||
const deactivateMastodonUsers = (accountIds: string[], reportId?: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) =>
|
||||
Promise.all(accountIds.map(accountId => {
|
||||
|
@ -309,56 +321,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, accountIds });
|
||||
dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountId });
|
||||
});
|
||||
};
|
||||
|
||||
const approveUsers = (accountIds: string[]) =>
|
||||
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_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, accountIds });
|
||||
dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountId });
|
||||
|
||||
if (features.mastodonAdmin) {
|
||||
return dispatch(approveMastodonUsers(accountIds));
|
||||
return dispatch(approveMastodonUser(accountId));
|
||||
} else {
|
||||
return dispatch(approvePleromaUsers(accountIds));
|
||||
return dispatch(approvePleromaUser(accountId));
|
||||
}
|
||||
};
|
||||
|
||||
const rejectUser = (accountId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
|
||||
const instance = state.instance;
|
||||
const features = getFeatures(instance);
|
||||
|
||||
dispatch({ type: ADMIN_USERS_REJECT_REQUEST, accountId });
|
||||
|
||||
if (features.mastodonAdmin) {
|
||||
return dispatch(rejectMastodonUser(accountId));
|
||||
} else {
|
||||
return dispatch(deleteUser(accountId));
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -562,6 +598,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 +636,10 @@ export {
|
|||
closeReports,
|
||||
fetchUsers,
|
||||
deactivateUsers,
|
||||
deleteUsers,
|
||||
approveUsers,
|
||||
deleteUser,
|
||||
approveUser,
|
||||
rejectUser,
|
||||
revokeName,
|
||||
deleteStatus,
|
||||
toggleStatusSensitivity,
|
||||
tagUsers,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -71,6 +71,7 @@ const ProfilePopper: React.FC<IProfilePopper> = ({ 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 = ({
|
|||
|
||||
<Stack space={withAccountNote || note ? 1 : 0}>
|
||||
<HStack alignItems='center' space={1}>
|
||||
<Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text>
|
||||
<Text theme='muted' size='sm' direction='ltr' truncate>@{acct ?? username}</Text>
|
||||
|
||||
{account.pleroma?.favicon && (
|
||||
<InstanceFavicon account={account} disabled={!withLinkToProfile} />
|
||||
|
|
|
@ -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<IUnapprovedAccount> = ({ 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 (
|
||||
<Account
|
||||
key={account.id}
|
||||
key={adminAccount.id}
|
||||
account={account}
|
||||
acct={`${adminAccount.username}@${adminAccount.domain}`}
|
||||
note={adminAccount?.invite_request || ''}
|
||||
action={(
|
||||
<AuthorizeRejectButtons
|
||||
|
|
|
@ -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<IEditIdentity> = () => {
|
|||
const instance = useInstance();
|
||||
const dispatch = useAppDispatch();
|
||||
const { account } = useOwnAccount();
|
||||
const { relay, signer } = useNostr();
|
||||
const { mutate } = useRequestName();
|
||||
|
||||
const { data: approvedNames } = useNames();
|
||||
const { data: pendingNames } = usePendingNames();
|
||||
|
||||
const admin = instance.nostr?.pubkey;
|
||||
const pubkey = account?.nostr?.pubkey;
|
||||
const [username, setUsername] = useState<string>('');
|
||||
|
||||
const { events: labels } = useNostrReq(
|
||||
(admin && pubkey)
|
||||
? [{ kinds: [1985], authors: [admin], '#L': ['nip05'], '#p': [pubkey] }]
|
||||
: [],
|
||||
);
|
||||
const [reason, setReason] = useState<string>('');
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const updateNip05 = async (nip05: string): Promise<void> => {
|
||||
if (account.source?.nostr?.nip05 === nip05) return;
|
||||
const updateName = async (name: string): Promise<void> => {
|
||||
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 (
|
||||
<Column label={intl.formatMessage(messages.title)}>
|
||||
<List>
|
||||
{labels.map((label) => {
|
||||
const identifier = label.tags.find(([name]) => name === 'l')?.[1];
|
||||
if (!identifier) return null;
|
||||
<div className='space-y-4'>
|
||||
<Form>
|
||||
<UsernameInput value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
<Textarea
|
||||
name='reason'
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
maxLength={500}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
value={reason}
|
||||
autoGrow
|
||||
required
|
||||
/>
|
||||
<Button theme='accent' onClick={submit}>
|
||||
<FormattedMessage id='edit_identity.request' defaultMessage='Request' />
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={identifier}
|
||||
label={
|
||||
<HStack alignItems='center' space={2}>
|
||||
<span>{identifier}</span>
|
||||
{(account.source?.nostr?.nip05 === identifier && account.acct !== identifier) && (
|
||||
<Tooltip text={intl.formatMessage(messages.unverified)}>
|
||||
<div>
|
||||
<Emoji className='h-4 w-4' emoji='⚠️' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
}
|
||||
isSelected={account.source?.nostr?.nip05 === identifier}
|
||||
onSelect={() => updateNip05(identifier)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ListItem label={<UsernameInput value={username} onChange={(e) => setUsername(e.target.value)} />}>
|
||||
<Button theme='accent' onClick={submit}>Add</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
{((approvedNames?.length ?? 0) > 0) && (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle title={<FormattedMessage id='edit_identity.names_title' defaultMessage='Names' />} />
|
||||
</CardHeader>
|
||||
|
||||
<List>
|
||||
{approvedNames?.map(({ username, domain }) => {
|
||||
const identifier = `${username}@${domain}`;
|
||||
if (!identifier) return null;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={identifier}
|
||||
label={
|
||||
<HStack alignItems='center' space={2}>
|
||||
<span>{identifier}</span>
|
||||
{(account.source?.nostr?.nip05 === identifier && account.acct !== identifier) && (
|
||||
<Tooltip text={intl.formatMessage(messages.unverified)}>
|
||||
<div>
|
||||
<Emoji className='h-4 w-4' emoji='⚠️' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
}
|
||||
isSelected={account.source?.nostr?.nip05 === identifier}
|
||||
onSelect={() => updateName(identifier)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
|
||||
{((pendingNames?.length ?? 0) > 0) && (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle title={<FormattedMessage id='edit_identity.pending_names_title' defaultMessage='Requested Names' />} />
|
||||
</CardHeader>
|
||||
|
||||
<List>
|
||||
{pendingNames?.map(({ username, domain }) => {
|
||||
const identifier = `${username}@${domain}`;
|
||||
if (!identifier) return null;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={identifier}
|
||||
label={
|
||||
<HStack alignItems='center' space={2}>
|
||||
<span>{identifier}</span>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
@ -119,4 +154,43 @@ const UsernameInput: React.FC<React.ComponentProps<typeof Input>> = (props) => {
|
|||
);
|
||||
};
|
||||
|
||||
interface NameRequestData {
|
||||
name: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function useRequestName() {
|
||||
const api = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: NameRequestData) => api.post('/api/v1/ditto/names', data),
|
||||
});
|
||||
}
|
||||
|
||||
function useNames() {
|
||||
const api = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['names', 'approved'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/api/v1/ditto/names?approved=true');
|
||||
return adminAccountSchema.array().parse(data);
|
||||
},
|
||||
placeholderData: [],
|
||||
});
|
||||
}
|
||||
|
||||
function usePendingNames() {
|
||||
const api = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['names', 'pending'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/api/v1/ditto/names?approved=false');
|
||||
return adminAccountSchema.array().parse(data);
|
||||
},
|
||||
placeholderData: [],
|
||||
});
|
||||
}
|
||||
|
||||
export default EditIdentity;
|
|
@ -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;
|
||||
|
|
|
@ -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<NotificationType, string> = {
|
|||
'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<NotificationType, MessageDescriptor> = defineMessages({
|
||||
const notificationMessages: Record<NotificationType, MessageDescriptor> = defineMessages({
|
||||
follow: {
|
||||
id: 'notification.follow',
|
||||
defaultMessage: '{name} followed you',
|
||||
|
@ -132,29 +135,32 @@ const messages: Record<NotificationType, MessageDescriptor> = 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 ? (
|
||||
<FormattedMessage
|
||||
id='notification.others'
|
||||
defaultMessage='+ {count, plural, one {# other} other {# others}}'
|
||||
values={{ count: totalCount - 1 }}
|
||||
/>
|
||||
) : '',
|
||||
others: '',
|
||||
});
|
||||
|
||||
return intl.formatMessage(messages[type], {
|
||||
return intl.formatMessage(notificationMessages[type], {
|
||||
acct,
|
||||
name,
|
||||
targetName,
|
||||
instance: instanceTitle,
|
||||
|
@ -274,6 +280,11 @@ const Notification: React.FC<INotification> = (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<INotification> = (props) => {
|
|||
showGroup={false}
|
||||
/>
|
||||
) : null;
|
||||
case 'ditto:name_grant':
|
||||
return (
|
||||
<Stack className='p-4'>
|
||||
<Button onClick={() => updateName(notification.name)}>
|
||||
<FormattedMessage
|
||||
id='notification.set_name' defaultMessage='Set name to {name}'
|
||||
values={{ name: notification.name }}
|
||||
/>
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
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,
|
||||
}),
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { ChangeEventHandler, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { setBadges as saveBadges } from 'soapbox/actions/admin';
|
||||
import { revokeName, setBadges as saveBadges } from 'soapbox/actions/admin';
|
||||
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
|
||||
import { useAccount } from 'soapbox/api/hooks';
|
||||
import { useSuggest, useVerify } from 'soapbox/api/hooks/admin';
|
||||
|
@ -25,6 +25,7 @@ const messages = defineMessages({
|
|||
userSuggested: { id: 'admin.users.user_suggested_message', defaultMessage: '@{acct} was suggested' },
|
||||
userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' },
|
||||
badgesSaved: { id: 'admin.users.badges_saved_message', defaultMessage: 'Custom badges updated.' },
|
||||
revokedName: { id: 'admin.users.revoked_name_message', defaultMessage: 'Name revoked.' },
|
||||
});
|
||||
|
||||
interface IAccountModerationModal {
|
||||
|
@ -88,6 +89,12 @@ const AccountModerationModal: React.FC<IAccountModerationModal> = ({ onClose, ac
|
|||
dispatch(deactivateUserModal(intl, account.id));
|
||||
};
|
||||
|
||||
const handleRevokeName = () => {
|
||||
dispatch(revokeName(account.id))
|
||||
.then(() => toast.success(intl.formatMessage(messages.revokedName)))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
dispatch(deleteUserModal(intl, account.id));
|
||||
};
|
||||
|
@ -151,6 +158,13 @@ const AccountModerationModal: React.FC<IAccountModerationModal> = ({ onClose, ac
|
|||
</List>
|
||||
|
||||
<List>
|
||||
{features.revokeName && (
|
||||
<ListItem
|
||||
label={<FormattedMessage id='account_moderation_modal.fields.revoke_name' defaultMessage='Revoke name' />}
|
||||
onClick={handleRevokeName}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ListItem
|
||||
label={<FormattedMessage id='account_moderation_modal.fields.deactivate' defaultMessage='Deactivate account' />}
|
||||
onClick={handleDeactivate}
|
||||
|
|
|
@ -74,6 +74,7 @@
|
|||
"account_moderation_modal.fields.badges": "Custom badges",
|
||||
"account_moderation_modal.fields.deactivate": "Deactivate account",
|
||||
"account_moderation_modal.fields.delete": "Delete account",
|
||||
"account_moderation_modal.fields.revoke_name": "Revoke name",
|
||||
"account_moderation_modal.fields.suggested": "Suggested in people to follow",
|
||||
"account_moderation_modal.fields.verified": "Verified account",
|
||||
"account_moderation_modal.info.id": "ID: {id}",
|
||||
|
@ -183,6 +184,7 @@
|
|||
"admin.users.actions.promote_to_moderator_message": "@{acct} was promoted to a moderator",
|
||||
"admin.users.badges_saved_message": "Custom badges updated.",
|
||||
"admin.users.remove_donor_message": "@{acct} was removed as a donor",
|
||||
"admin.users.revoked_name_message": "Name revoked.",
|
||||
"admin.users.set_donor_message": "@{acct} was set as a donor",
|
||||
"admin.users.user_deactivated_message": "@{acct} was deactivated",
|
||||
"admin.users.user_deleted_message": "@{acct} was deleted",
|
||||
|
@ -642,6 +644,10 @@
|
|||
"edit_federation.save": "Save",
|
||||
"edit_federation.success": "{host} federation was updated",
|
||||
"edit_federation.unlisted": "Force posts unlisted",
|
||||
"edit_identity.names_title": "Names",
|
||||
"edit_identity.pending_names_title": "Requested Names",
|
||||
"edit_identity.reason_placeholder": "Why do you want this name?",
|
||||
"edit_identity.request": "Request",
|
||||
"edit_password.header": "Change Password",
|
||||
"edit_profile.error": "Profile update failed",
|
||||
"edit_profile.fields.accepts_email_list_label": "Subscribe to newsletter",
|
||||
|
@ -1159,6 +1165,7 @@
|
|||
"nostr_signup.siwe.action": "Sign in with extension",
|
||||
"nostr_signup.siwe.alt": "Sign in with key",
|
||||
"nostr_signup.siwe.title": "Sign in",
|
||||
"notification.ditto:name_grant": "You were granted the name {acct}",
|
||||
"notification.favourite": "{name} liked your post",
|
||||
"notification.follow": "{name} followed you",
|
||||
"notification.follow_request": "{name} has requested to follow you",
|
||||
|
@ -1168,7 +1175,6 @@
|
|||
"notification.mentioned": "{name} mentioned you",
|
||||
"notification.move": "{name} moved to {targetName}",
|
||||
"notification.name": "{link}{others}",
|
||||
"notification.others": "+ {count, plural, one {# other} other {# others}}",
|
||||
"notification.pleroma:chat_mention": "{name} sent you a message",
|
||||
"notification.pleroma:emoji_reaction": "{name} reacted to your post",
|
||||
"notification.pleroma:event_reminder": "An event you are participating in starts soon",
|
||||
|
@ -1176,8 +1182,10 @@
|
|||
"notification.pleroma:participation_request": "{name} wants to join your event",
|
||||
"notification.poll": "A poll you have voted in has ended",
|
||||
"notification.reblog": "{name} reposted your post",
|
||||
"notification.set_name": "Set name to {name}",
|
||||
"notification.status": "{name} just posted",
|
||||
"notification.update": "{name} edited a post you interacted with",
|
||||
"notification.update_name_success": "Name updated successfully",
|
||||
"notification.user_approved": "Welcome to {instance}!",
|
||||
"notifications.filter.all": "All",
|
||||
"notifications.filter.boosts": "Reposts",
|
||||
|
|
|
@ -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<Status>,
|
||||
target: null as EmbeddedEntity<Account>, // move
|
||||
type: '',
|
||||
|
|
|
@ -19,6 +19,8 @@ import {
|
|||
ADMIN_USERS_DELETE_SUCCESS,
|
||||
ADMIN_USERS_APPROVE_REQUEST,
|
||||
ADMIN_USERS_APPROVE_SUCCESS,
|
||||
ADMIN_USERS_REJECT_REQUEST,
|
||||
ADMIN_USERS_REJECT_SUCCESS,
|
||||
} from 'soapbox/actions/admin';
|
||||
import { normalizeAdminReport, normalizeAdminAccount } from 'soapbox/normalizers';
|
||||
import { normalizeId } from 'soapbox/utils/normalizers';
|
||||
|
@ -120,22 +122,18 @@ function importUsers(state: State, users: APIUser[], filters: Filter[], page: nu
|
|||
});
|
||||
}
|
||||
|
||||
function deleteUsers(state: State, accountIds: string[]): State {
|
||||
function deleteUser(state: State, accountId: string): State {
|
||||
return state.withMutations(state => {
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { accountSchema } from './account';
|
||||
|
||||
const adminAccountSchema = z.object({
|
||||
id: z.string(),
|
||||
account: accountSchema,
|
||||
username: z.string(),
|
||||
domain: z.string(),
|
||||
created_at: z.string().datetime(),
|
||||
email: z.string().email().nullish().catch(null),
|
||||
ip: z.string().ip().nullish(),
|
||||
ips: z.string().ip().array().nullish(),
|
||||
locale: z.string(),
|
||||
invite_request: z.string().nullish(),
|
||||
role: z.string().nullish(),
|
||||
confirmed: z.boolean().catch(true),
|
||||
approved: z.boolean().catch(true),
|
||||
disabled: z.boolean().catch(false),
|
||||
silenced: z.boolean().catch(false),
|
||||
suspended: z.boolean().catch(false),
|
||||
sensitized: z.boolean().catch(false),
|
||||
});
|
||||
|
||||
type AdminAccount = z.infer<typeof adminAccountSchema>;
|
||||
|
||||
export { adminAccountSchema, AdminAccount };
|
|
@ -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<typeof notificationSchema>;
|
||||
|
|
|
@ -944,6 +944,9 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
*/
|
||||
resetPassword: v.software === PLEROMA,
|
||||
|
||||
/** Admin can revoke the user's identity (without deleting their account). */
|
||||
revokeName: v.software === DITTO,
|
||||
|
||||
/**
|
||||
* Ability to post statuses in Markdown, BBCode, and HTML.
|
||||
* @see POST /api/v1/statuses
|
||||
|
|
|
@ -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. */
|
||||
|
|
Ładowanie…
Reference in New Issue