diff --git a/CHANGELOG.md b/CHANGELOG.md index d2b751d0..0bf09c7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.144.0] - Not released +### Added +- Account pause functionality. Users can now temporarily suspend their account + from the Settings page. Paused accounts are distinguished from deleted + accounts in the UI. ## [1.143.0] - 2025-12-17 ### Added diff --git a/src/components/settings/deactivate.jsx b/src/components/settings/deactivate.jsx index 4bd2d720..d33edd1b 100644 --- a/src/components/settings/deactivate.jsx +++ b/src/components/settings/deactivate.jsx @@ -11,14 +11,16 @@ import { Icon } from '../fontawesome-icons'; import { SettingsPage } from './layout'; import { suspendMe, unauthenticated } from './../../redux/action-creators'; import styles from './settings.module.scss'; +import { useNouter } from '../../services/nouter'; export default function PrivacyPage() { const dispatch = useDispatch(); + const { navigate } = useNouter(); const userInfo = useSelector((state) => state.user); const formStatus = useSelector((state) => state.settingsForms.deactivateStatus); const usernameS = useMemo(() => { - if (userInfo.username.endsWith('s')) { + if (userInfo.username?.endsWith('s')) { return `${userInfo.username}\u2019`; } return `${userInfo.username}\u2019s`; @@ -37,10 +39,11 @@ export default function PrivacyPage() { } doSequence(dispatch)( (dispatch) => dispatch(suspendMe(password)), + () => navigate(`/${userInfo.username}`), (dispatch) => dispatch(unauthenticated()), ); }, - [canSubmit, dispatch, password], + [canSubmit, dispatch, navigate, password, userInfo.username], ); return ( diff --git a/src/components/settings/pause.jsx b/src/components/settings/pause.jsx new file mode 100644 index 00000000..630f8422 --- /dev/null +++ b/src/components/settings/pause.jsx @@ -0,0 +1,161 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import cn from 'classnames'; +import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; + +import { Throbber } from '../throbber'; +import { doSequence } from '../../redux/async-helpers'; +import { pluralForm } from '../../utils'; +import UserName from '../user-name'; +import { Icon } from '../fontawesome-icons'; +import { SettingsPage } from './layout'; +import { pauseMe, unauthenticated } from '../../redux/action-creators'; +import styles from './settings.module.scss'; +import { useNouter } from '../../services/nouter'; + +export default function PausePage() { + const dispatch = useDispatch(); + const { navigate } = useNouter(); + const userInfo = useSelector((state) => state.user); + const formStatus = useSelector((state) => state.settingsForms.pauseStatus); + + const usernameS = useMemo(() => { + if (userInfo.username?.endsWith('s')) { + return `${userInfo.username}\u2019`; + } + return `${userInfo.username}\u2019s`; + }, [userInfo.username]); + + const [password, setPassword] = useState(''); + const [message, setMessage] = useState(userInfo.preferences?.pauseMessage || ''); + const onPasswordChange = useCallback(({ target }) => setPassword(target.value), []); + const onMessageChange = useCallback(({ target }) => setMessage(target.value), []); + + const canSubmit = useMemo(() => password.trim() !== '', [password]); + const submit = useCallback( + (e) => { + e.preventDefault(); + if (!canSubmit) { + alert('Please enter password'); + return; + } + doSequence(dispatch)( + (dispatch) => dispatch(pauseMe(password, message.trim())), + () => navigate(`/${userInfo.username}`), + (dispatch) => dispatch(unauthenticated()), + ); + }, + [canSubmit, dispatch, navigate, password, message, userInfo.username], + ); + + return ( + +
+

When an account is paused, the following will be hidden:

+ +

+ Your comments in other user’s posts will stay, and your username will be used for + attribution. +

+

Once you pause your account, you can reactivate it at any time.

+
+ +
+
+
+ + +
+
+ + +
+
+ {' '} + {formStatus.loading && } +

(You will be signed out immediately)

+
+ {formStatus.error && ( +

+ {formStatus.errorText} +

+ )} +
+
+
+ ); +} + +function OrphanGroupsWarning() { + const managedGroups = useSelector((state) => state.managedGroups); + const orphanGroups = managedGroups.filter( + (g) => g.administrators.length === 1 && g.isRestricted === '0', + ); + + if (orphanGroups.length === 0) { + return null; + } + + if (orphanGroups.length === 1) { + const [g] = orphanGroups; + return ( +
+

+ Please note: there is a non-restricted group{' '} + {g.screenName} in which you are the only administrator. +

+

+ If you pause your account, this group will become restricted and no one will be able to + create posts in it. Please add additional administrators to these groups before you pause + your account to prevent that. +

+
+ ); + } + + return ( +
+

+ Please note: there are{' '} + {pluralForm(orphanGroups.length, 'non-restricted group')} in which you are the only + administrator: +

+
    + {orphanGroups.map((g) => ( +
  1. + {g.screenName} +
  2. + ))} +
+

+ If you pause your account, these groups will become restricted and no one will be able to + create posts in them. Please add additional administrators to these groups before you pause + your account to prevent that. +

+
+ ); +} diff --git a/src/components/settings/profile.jsx b/src/components/settings/profile.jsx index 1586800f..4ea22da9 100644 --- a/src/components/settings/profile.jsx +++ b/src/components/settings/profile.jsx @@ -18,7 +18,8 @@ export default function ProfilePage() {

- You can delete your account here. + You can pause your account or{' '} + delete it.

diff --git a/src/components/settings/routes.jsx b/src/components/settings/routes.jsx index b83216ff..76fc6d97 100644 --- a/src/components/settings/routes.jsx +++ b/src/components/settings/routes.jsx @@ -9,6 +9,7 @@ const AppearancePage = lazyRetry(() => import('./appearance')); const PrivacyPage = lazyRetry(() => import('./privacy')); const NotificationsPage = lazyRetry(() => import('./notififications')); const DeactivatePage = lazyRetry(() => import('./deactivate')); +const PausePage = lazyRetry(() => import('./pause')); const AuthSessionsPage = lazyRetry(() => import('./auth-sessions')); const SanitizeMediaPage = lazyRetry(() => import('./sanitize-media')); @@ -23,6 +24,7 @@ export function settingsRoute(rootPath) { + {tokensRoute('app-tokens')} diff --git a/src/components/user-feed-status.jsx b/src/components/user-feed-status.jsx index 25b40ca1..9dd27e32 100644 --- a/src/components/user-feed-status.jsx +++ b/src/components/user-feed-status.jsx @@ -11,7 +11,8 @@ export default function UserFeedStatus(props) { {props.isGone ? ( - Deleted + {' '} + {props.goneStatus === 'paused' ? 'Paused' : 'Deleted'} ) : props.isPrivate === '1' ? ( diff --git a/src/components/user-feed.jsx b/src/components/user-feed.jsx index 412b82a7..610fbc41 100644 --- a/src/components/user-feed.jsx +++ b/src/components/user-feed.jsx @@ -39,8 +39,7 @@ class UserFeed extends Component { return (

- {viewUser.screenName} account has been deleted. This page still exists as a stub - for the username, but this {viewUser.type} is not in FreeFeed anymore. +

); @@ -106,3 +105,30 @@ function select(state) { } export default connect(select)(UserFeed); + +function UserGonePanel({ user }) { + if (!user.isGone) { + return null; + } + if (user.goneStatus === 'paused') { + if (user.description) { + return ( + <> + {user.screenName} has paused their account and left a message:{' '} + {user.description} + + ); + } + return ( + <> + {user.screenName} has paused their account. They may return someday. + + ); + } + return ( + <> + {user.screenName} account has been deleted. This page still exists as a stub for the + username, but this {user.type} is not in FreeFeed anymore. + + ); +} diff --git a/src/components/user-profile-head.jsx b/src/components/user-profile-head.jsx index 8254834c..c8dbdfac 100644 --- a/src/components/user-profile-head.jsx +++ b/src/components/user-profile-head.jsx @@ -289,7 +289,7 @@ export const UserProfileHead = withNouter(
- + {user.isGone ? null : }
{isAuthenticated && !isCurrentUser && ( <> @@ -359,7 +359,7 @@ function PrivacyIndicator({ user }) { if (user.isGone) { icon = faUserSlash; - label = 'Deleted user'; + label = user.goneStatus === 'paused' ? 'Paused user' : 'Deleted user'; } else if (user.isPrivate === '1') { icon = faLock; label = `Private ${userOrGroup}`; diff --git a/src/redux/action-creators.js b/src/redux/action-creators.js index 045790c4..00838f23 100644 --- a/src/redux/action-creators.js +++ b/src/redux/action-creators.js @@ -1137,6 +1137,14 @@ export function suspendMe(password) { }; } +export function pauseMe(password, message) { + return { + type: ActionTypes.PAUSE_USER, + apiRequest: Api.pauseMe, + payload: { password, message }, + }; +} + export function resumeMe(resumeToken) { return { type: ActionTypes.ACTIVATE_USER, diff --git a/src/redux/action-types.js b/src/redux/action-types.js index 11360d83..b2778056 100644 --- a/src/redux/action-types.js +++ b/src/redux/action-types.js @@ -152,6 +152,7 @@ export const DELETE_HOME_FEED = 'DELETE_HOME_FEED'; export const REORDER_HOME_FEEDS = 'REORDER_HOME_FEEDS'; export const GET_ALL_SUBSCRIPTIONS = 'GET_ALL_SUBSCRIPTIONS'; export const DEACTIVATE_USER = 'DEACTIVATE_USER'; +export const PAUSE_USER = 'PAUSE_USER'; export const ACTIVATE_USER = 'ACTIVATE_USER'; export const CREATE_ATTACHMENT = 'CREATE_ATTACHMENT'; export const SET_ATTACHMENT = 'SET_ATTACHMENT'; diff --git a/src/redux/reducers/settings-forms.js b/src/redux/reducers/settings-forms.js index d8a9895a..c47208bb 100644 --- a/src/redux/reducers/settings-forms.js +++ b/src/redux/reducers/settings-forms.js @@ -8,6 +8,7 @@ import { UPDATE_USER_NOTIFICATION_PREFERENCES, DEACTIVATE_USER, ACTIVATE_USER, + PAUSE_USER, } from '../action-types'; import { initialAsyncState, asyncState } from '../async-helpers'; import { setOnLocationChange } from './helpers'; @@ -26,5 +27,6 @@ export const settingsForms = combineReducers({ ), privacyStatus: asyncState(UPDATE_USER, setOnLocationChange(initialAsyncState)), deactivateStatus: asyncState(DEACTIVATE_USER, setOnLocationChange(initialAsyncState)), + pauseStatus: asyncState(PAUSE_USER, setOnLocationChange(initialAsyncState)), activateStatus: asyncState(ACTIVATE_USER, setOnLocationChange(initialAsyncState)), }); diff --git a/src/services/api.js b/src/services/api.js index 822b6b54..66c3e52e 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -726,6 +726,10 @@ export function suspendMe({ password }) { return fetch(`${apiPrefix}/users/suspend-me`, postRequestOptions('POST', { password })); } +export function pauseMe({ password, message }) { + return fetch(`${apiPrefix}/users/pause-me`, postRequestOptions('POST', { password, message })); +} + export function resumeMe({ resumeToken }) { return fetch(`${apiPrefix}/users/resume-me`, postRequestOptions('POST', { resumeToken })); }