Support bites

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-08-19 23:19:44 +02:00
parent 614168d079
commit 77c21723e5
12 changed files with 121 additions and 9 deletions

View file

@ -110,6 +110,10 @@ const BIRTHDAY_REMINDERS_FETCH_REQUEST = 'BIRTHDAY_REMINDERS_FETCH_REQUEST' as c
const BIRTHDAY_REMINDERS_FETCH_SUCCESS = 'BIRTHDAY_REMINDERS_FETCH_SUCCESS' as const;
const BIRTHDAY_REMINDERS_FETCH_FAIL = 'BIRTHDAY_REMINDERS_FETCH_FAIL' as const;
const ACCOUNT_BITE_REQUEST = 'ACCOUNT_BITE_REQUEST' as const;
const ACCOUNT_BITE_SUCCESS = 'ACCOUNT_BITE_SUCCESS' as const;
const ACCOUNT_BITE_FAIL = 'ACCOUNT_BITE_FAIL' as const;
const maybeRedirectLogin = (error: { response: PlfeResponse }, history?: History) => {
// The client is unauthorized - redirect to login.
if (history && error?.response?.status === 401) {
@ -809,6 +813,37 @@ const fetchBirthdayReminders = (month: number, day: number) =>
});
};
const biteAccount = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const client = getClient(getState);
dispatch(biteAccountRequest(accountId));
return client.accounts.biteAccount(accountId)
.then(() => {
return dispatch(biteAccountSuccess(accountId));
})
.catch(error => {
dispatch(biteAccountFail(accountId, error));
throw error;
});
};
const biteAccountRequest = (accountId: string) => ({
type: ACCOUNT_BITE_REQUEST,
accountId,
});
const biteAccountSuccess = (accountId: string) => ({
type: ACCOUNT_BITE_SUCCESS,
});
const biteAccountFail = (accountId: string, error: unknown) => ({
type: ACCOUNT_BITE_FAIL,
accountId,
error,
});
export {
ACCOUNT_CREATE_REQUEST,
ACCOUNT_CREATE_SUCCESS,
@ -879,6 +914,9 @@ export {
BIRTHDAY_REMINDERS_FETCH_REQUEST,
BIRTHDAY_REMINDERS_FETCH_SUCCESS,
BIRTHDAY_REMINDERS_FETCH_FAIL,
ACCOUNT_BITE_REQUEST,
ACCOUNT_BITE_SUCCESS,
ACCOUNT_BITE_FAIL,
createAccount,
fetchAccount,
fetchAccountByUsername,
@ -957,4 +995,8 @@ export {
accountSearch,
accountLookup,
fetchBirthdayReminders,
biteAccount,
biteAccountRequest,
biteAccountSuccess,
biteAccountFail,
};

View file

@ -297,14 +297,14 @@ const scrollTopNotifications = (top: boolean) =>
};
const setFilter = (filterType: FilterType, abort?: boolean) =>
(dispatch: AppDispatch) => {
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({
type: NOTIFICATIONS_FILTER_SET,
path: ['notifications', 'quickFilter', 'active'],
value: filterType,
});
dispatch(expandNotifications(undefined, undefined, abort));
dispatch(saveSettings());
if (getSettings(getState()).getIn(['notifications', 'quickFilter', 'active']) !== filterType) dispatch(saveSettings());
};
const markReadNotifications = () =>

View file

@ -29,7 +29,7 @@ const useTimelineStream = (stream: string, params: { list?: string; tag?: string
const streamingUrl = instance.configuration.urls.streaming;
const connect = async () => {
if (!socket.current) {
if (!socket.current && streamingUrl) {
socket.current = client.streaming.connect();
socket.current.subscribe(stream, params);

View file

@ -75,7 +75,7 @@ interface IAccount {
actionIcon?: string;
actionTitle?: string;
/** Override other actions for specificity like mute/unmute. */
actionType?: 'muting' | 'blocking' | 'follow_request';
actionType?: 'muting' | 'blocking' | 'follow_request' | 'biting';
avatarSize?: number;
hidden?: boolean;
hideActions?: boolean;

View file

@ -4,7 +4,7 @@ import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { blockAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts';
import { biteAccount, blockAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts';
import { mentionCompose, directCompose } from 'soapbox/actions/compose';
import { blockDomain, unblockDomain } from 'soapbox/actions/domain-blocks';
import { openModal } from 'soapbox/actions/modals';
@ -57,6 +57,7 @@ const messages = defineMessages({
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Mutes' },
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
bite: { id: 'account.bite', defaultMessage: 'Bite @{name}' },
removeFromFollowers: { id: 'account.remove_from_followers', defaultMessage: 'Remove this follower' },
adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{name}' },
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
@ -69,6 +70,8 @@ const messages = defineMessages({
removeFromFollowersConfirm: { id: 'confirmations.remove_from_followers.confirm', defaultMessage: 'Remove' },
userEndorsed: { id: 'account.endorse.success', defaultMessage: 'You are now featuring @{acct} on your profile' },
userUnendorsed: { id: 'account.unendorse.success', defaultMessage: 'You are no longer featuring @{acct}' },
userBit: { id: 'account.bite.success', defaultMessage: 'You have bit @{acct}' },
userBiteFail: { id: 'account.bite.fail', defaultMessage: 'Failed to bite @{acct}' },
profileExternal: { id: 'account.profile_external', defaultMessage: 'View profile on {domain}' },
header: { id: 'account.header.alt', defaultMessage: 'Profile header' },
subscribeFeed: { id: 'account.rss_feed', defaultMessage: 'Subscribe to RSS feed' },
@ -172,6 +175,12 @@ const Header: React.FC<IHeader> = ({ account }) => {
}
};
const onBite = () => {
dispatch(biteAccount(account.id))
.then(() => toast.success(intl.formatMessage(messages.userBit, { acct: account.acct })))
.catch(() => toast.error(intl.formatMessage(messages.userBiteFail, { acct: account.acct })));
};
const onReport = () => {
dispatch(initReport(ReportableEntities.ACCOUNT, account));
};
@ -405,6 +414,14 @@ const Header: React.FC<IHeader> = ({ account }) => {
});
}
if (features.bites) {
menu.push({
text: intl.formatMessage(messages.bite, { name: account.username }),
action: onBite,
icon: require('@tabler/icons/outline/pacman.svg'),
});
}
menu.push(null);
if (features.removeFromFollowers && account.relationship?.followed_by) {

View file

@ -55,6 +55,7 @@ const icons: Partial<Record<NotificationType, string>> = {
event_reminder: require('@tabler/icons/outline/calendar-time.svg'),
participation_request: require('@tabler/icons/outline/calendar-event.svg'),
participation_accepted: require('@tabler/icons/outline/calendar-event.svg'),
bite: require('@tabler/icons/outline/pacman.svg'),
};
const messages: Record<NotificationType, MessageDescriptor> = defineMessages({
@ -130,6 +131,10 @@ const messages: Record<NotificationType, MessageDescriptor> = defineMessages({
id: 'notification.moderation_warning',
defaultMessage: 'You have received a moderation warning',
},
bite: {
id: 'notification.bite',
defaultMessage: '{name} has bit you',
},
});
const buildMessage = (
@ -311,6 +316,16 @@ const Notification: React.FC<INotification> = (props) => {
withRelationship
/>
) : null;
case 'bite':
return account && typeof account === 'object' ? (
<AccountContainer
id={account.id}
hidden={hidden}
avatarSize={avatarSize}
actionType='biting'
withRelationship
/>
) : null;
case 'move':
return account && typeof account === 'object' && notification.target && typeof notification.target === 'object' ? (
<AccountContainer

View file

@ -8,11 +8,13 @@ import {
unmuteAccount,
authorizeFollowRequest,
rejectFollowRequest,
biteAccount,
} from 'soapbox/actions/accounts';
import { openModal } from 'soapbox/actions/modals';
import { useFollow } from 'soapbox/api/hooks';
import { Button, HStack } from 'soapbox/components/ui';
import { useAppDispatch, useFeatures, useLoggedIn } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import type { Account } from 'soapbox/normalizers';
@ -30,13 +32,16 @@ const messages = defineMessages({
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
bite: { id: 'account.bite', defaultMessage: 'Bite @{name}' },
userBit: { id: 'account.bite.success', defaultMessage: 'You have bit @{acct}' },
userBiteFail: { id: 'account.bite.fail', defaultMessage: 'Failed to bite @{acct}' },
});
interface IActionButton {
/** Target account for the action. */
account: Account;
/** Type of action to prioritize, eg on Blocks and Mutes pages. */
actionType?: 'muting' | 'blocking' | 'follow_request';
actionType?: 'muting' | 'blocking' | 'follow_request' | 'biting';
/** Displays shorter text on the "Awaiting approval" button. */
small?: boolean;
}
@ -86,6 +91,12 @@ const ActionButton: React.FC<IActionButton> = ({ account, actionType, small }) =
dispatch(rejectFollowRequest(account.id));
};
const handleBite = () => {
dispatch(biteAccount(account.id))
.then(() => toast.success(intl.formatMessage(messages.userBit, { acct: account.acct })))
.catch(() => toast.error(intl.formatMessage(messages.userBiteFail, { acct: account.acct })));
};
const handleRemoteFollow = () => {
dispatch(openModal('UNAUTHORIZED', {
action: 'FOLLOW',
@ -126,6 +137,21 @@ const ActionButton: React.FC<IActionButton> = ({ account, actionType, small }) =
);
};
/** Handles actionType='blocking' */
const bitingAction = () => {
const text = intl.formatMessage(messages.bite, { name: account.username });
return (
<Button
theme='secondary'
size='sm'
text={text}
onClick={handleBite}
icon={require('@tabler/icons/outline/pacman.svg')}
/>
);
};
const followRequestAction = () => {
if (account.relationship?.followed_by) return null;
@ -201,6 +227,8 @@ const ActionButton: React.FC<IActionButton> = ({ account, actionType, small }) =
return blockingAction();
} else if (actionType === 'follow_request') {
return followRequestAction();
} else if (actionType === 'biting') {
return bitingAction();
}
}

View file

@ -7,6 +7,9 @@
"account.badges.bot": "Bot",
"account.birthday": "Born {date}",
"account.birthday_today": "Birthday is today!",
"account.bite": "Bite @{name}",
"account.bite.fail": "Failed to bite @{acct}",
"account.bite.success": "You have bit @{acct}",
"account.block": "Block @{name}",
"account.block_domain": "Hide everything from {domain}",
"account.blocked": "Blocked",
@ -1081,6 +1084,7 @@
"new_group_panel.title": "Create group",
"notification.admin.report": "{name} reported {target}",
"notification.admin.sign_up": "{name} signed up",
"notification.bite": "{name} has bit you",
"notification.favourite": "{name} liked your post",
"notification.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you",

View file

@ -7,6 +7,9 @@
"account.badges.bot": "Bot",
"account.birthday": "Urodzony(-a) {date}",
"account.birthday_today": "Ma dziś urodziny!",
"account.bite": "Ugryź @{name}",
"account.bite.fail": "Nie udało się ugryźć @{acct}",
"account.bite.success": "Ugryziono @{acct}",
"account.block": "Blokuj @{name}",
"account.block_domain": "Blokuj wszystko z {domain}",
"account.blocked": "Zablokowany(-a)",
@ -1081,6 +1084,7 @@
"new_group_panel.title": "Utwórz grupę",
"notification.admin.report": "{name} zgłosił(a) {target}",
"notification.admin.sign_up": "{name} zarejestrował(a) się",
"notification.bite": "{name} ugryzł(a) Cię",
"notification.favourite": "{name} dodał(a) Twój wpis do ulubionych",
"notification.follow": "{name} zaczął(-ęła) Cię obserwować",
"notification.follow_request": "{name} poprosił(a) Cię o możliwość obserwacji",

View file

@ -68,7 +68,7 @@ const minifyNotification = (notification: Notification) => {
created_at: string;
id: string;
} & (
| { type: 'follow' | 'follow_request' | 'admin.sign_up' }
| { type: 'follow' | 'follow_request' | 'admin.sign_up' | 'bite' }
| {
type: 'mention' | 'status' | 'reblog' | 'favourite' | 'poll' | 'update' | 'event_reminder';
status: string;

View file

@ -279,7 +279,7 @@ const makeGetOtherAccounts = () => createSelector([
], (accounts, authUserIds, me) =>
authUserIds.reduce((list: ImmutableList<any>, id: string) => {
if (id === me) return list;
const account = accounts[id];
const account = accounts?.[id];
return account ? list.push(account) : list;
}, ImmutableList()),
);

View file

@ -1,5 +1,5 @@
import { getFeatures, PLEROMA, type Instance } from 'pl-api';
import { getFeatures, PLEROMA, TOKI, type Instance } from 'pl-api';
import type { RootState } from 'soapbox/store';
@ -11,6 +11,8 @@ const getInstanceScopes = (instance: Instance) => {
const v = getFeatures(instance).version;
switch (v.software) {
case TOKI:
return 'read write follow push write:bites';
case PLEROMA:
return 'read write follow push admin';
default: