Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/components/settings/deactivate.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand All @@ -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 (
Expand Down
161 changes: 161 additions & 0 deletions src/components/settings/pause.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<SettingsPage title="Pause account">
<section className={styles.formSection}>
<p>When an account is paused, the following will be hidden:</p>
<ul>
<li>All your posts</li>
<li>All your images and other attachments</li>
<li>All your likes</li>
</ul>
<p>
Your comments in other user&#x2019;s posts will stay, and your username will be used for
attribution.
</p>
<p>Once you pause your account, you can reactivate it at any time.</p>
</section>
<OrphanGroupsWarning />
<section className={styles.formSection}>
<form onSubmit={submit}>
<div className="form-group">
<label htmlFor="message-input">
Leave a message to be displayed on your profile (optional):
</label>
<textarea
id="message-input"
className="form-control"
name="message"
rows={3}
placeholder="Just taking some time off"
value={message}
onChange={onMessageChange}
></textarea>
</div>
<div className="form-group">
<label htmlFor="password-input">Enter @{usernameS} password to proceed:</label>
<input
id="password-input"
className="form-control narrow-input"
type="password"
name="password"
autoComplete="current-password"
value={password}
onChange={onPasswordChange}
/>
</div>
<div className="form-group">
<button className={cn('btn btn-danger', canSubmit || 'disabled')} type="submit">
{formStatus.loading ? 'Pausing account…' : 'Pause my account'}
</button>{' '}
{formStatus.loading && <Throbber />}
<p className="help-block">(You will be signed out immediately)</p>
</div>
{formStatus.error && (
<p className="alert alert-danger" role="alert">
{formStatus.errorText}
</p>
)}
</form>
</section>
</SettingsPage>
);
}

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 (
<section className={styles.formSection}>
<p>
<Icon icon={faExclamationTriangle} /> Please note: there is a non-restricted group{' '}
<UserName user={g}>{g.screenName}</UserName> in which you are the only administrator.
</p>
<p>
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.
</p>
</section>
);
}

return (
<section className={styles.formSection}>
<p>
<Icon icon={faExclamationTriangle} /> Please note: there are{' '}
{pluralForm(orphanGroups.length, 'non-restricted group')} in which you are the only
administrator:
</p>
<ol>
{orphanGroups.map((g) => (
<li key={g.id}>
<UserName user={g}>{g.screenName}</UserName>
</li>
))}
</ol>
<p>
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.
</p>
</section>
);
}
3 changes: 2 additions & 1 deletion src/components/settings/profile.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export default function ProfilePage() {

<section className={styles.formSection}>
<p className="text-muted">
You can delete your account <Link to="/settings/deactivate">here</Link>.
You can <Link to="/settings/pause">pause</Link> your account or{' '}
<Link to="/settings/deactivate">delete</Link> it.
</p>
</section>
</SettingsPage>
Expand Down
2 changes: 2 additions & 0 deletions src/components/settings/routes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand All @@ -23,6 +24,7 @@ export function settingsRoute(rootPath) {
<Route path="appearance" component={AppearancePage} />
<Route path="notifications" component={NotificationsPage} />
<Route path="deactivate" component={DeactivatePage} />
<Route path="pause" component={PausePage} />
<Route path="sanitize-media" component={SanitizeMediaPage} />
{tokensRoute('app-tokens')}
</Switch>
Expand Down
3 changes: 2 additions & 1 deletion src/components/user-feed-status.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export default function UserFeedStatus(props) {
<span>
{props.isGone ? (
<span>
<Icon icon={faUserSlash} className="status-icon" /> Deleted
<Icon icon={faUserSlash} className="status-icon" />{' '}
{props.goneStatus === 'paused' ? 'Paused' : 'Deleted'}
</span>
) : props.isPrivate === '1' ? (
<span>
Expand Down
30 changes: 28 additions & 2 deletions src/components/user-feed.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ class UserFeed extends Component {
return (
<div className="box-body">
<p className="alert alert-warning">
<b>{viewUser.screenName}</b> account has been deleted. This page still exists as a stub
for the username, but this {viewUser.type} is not in FreeFeed anymore.
<UserGonePanel user={viewUser} />
</p>
</div>
);
Expand Down Expand Up @@ -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 (
<>
<b>{user.screenName}</b> has paused their account and left a message:{' '}
<em>{user.description}</em>
</>
);
}
return (
<>
<b>{user.screenName}</b> has paused their account. They may return someday.
</>
);
}
return (
<>
<b>{user.screenName}</b> account has been deleted. This page still exists as a stub for the
username, but this {user.type} is not in FreeFeed anymore.
</>
);
}
4 changes: 2 additions & 2 deletions src/components/user-profile-head.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
const emptyArray = [];

export const UserProfileHead = withNouter(
withKey(({ router }) => router.params.userName)(function UserProfileHead({ router }) {

Check warning on line 61 in src/components/user-profile-head.jsx

View workflow job for this annotation

GitHub Actions / build (22)

Function 'UserProfileHead' has a complexity of 24. Maximum allowed is 20

Check warning on line 61 in src/components/user-profile-head.jsx

View workflow job for this annotation

GitHub Actions / build (20)

Function 'UserProfileHead' has a complexity of 24. Maximum allowed is 20
const username = router.params.userName.toLowerCase();
const dispatch = useDispatch();
const dataStatus = useSelector(
Expand Down Expand Up @@ -289,7 +289,7 @@
</div>
</div>
<div className={styles.description}>
<PieceOfText text={user.description} isExpanded={true} />
{user.isGone ? null : <PieceOfText text={user.description} isExpanded={true} />}
</div>
{isAuthenticated && !isCurrentUser && (
<>
Expand Down Expand Up @@ -359,7 +359,7 @@

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}`;
Expand Down
8 changes: 8 additions & 0 deletions src/redux/action-creators.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/redux/action-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions src/redux/reducers/settings-forms.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)),
});
4 changes: 4 additions & 0 deletions src/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}
Expand Down
Loading