Skip to content
Open
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
74 changes: 72 additions & 2 deletions packages/public-api/src/apis/reddit/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,47 @@ export type ModeratorPermission =
| 'channels'
| 'community_chat';

export type UserAccountStatus = 'active' | 'locked' | 'suspended' | 'unknown';

type UserAccountStatusData = {
accountStatus?: UserAccountStatus | string | undefined;
isAccountLocked?: boolean | null | undefined;
isLocked?: boolean | null | undefined;
isSuspended?: boolean | null | undefined;
};

type UserProtoWithAccountStatus = UserProto & UserAccountStatusData;

function normalizeUserAccountStatus(data: UserAccountStatusData): UserAccountStatus {
const accountStatus =
typeof data.accountStatus === 'string'
? data.accountStatus.toLowerCase().replace(/^user_account_status_/, '')
: undefined;

if (
accountStatus === 'active' ||
accountStatus === 'locked' ||
accountStatus === 'suspended' ||
accountStatus === 'unknown'
) {
return accountStatus;
}

if (data.isAccountLocked === true || data.isLocked === true) {
return 'locked';
}

if (data.isSuspended === true) {
return 'suspended';
}

if (data.isAccountLocked === false || data.isLocked === false || data.isSuspended === false) {
return 'active';
}

return 'unknown';
}

export type CreateRelationshipOptions = {
subredditName: string;
username: string;
Expand Down Expand Up @@ -220,14 +261,15 @@ export class User {
#hasVerifiedEmail: boolean;
#displayName: string;
#about: string;
#accountStatus: UserAccountStatus;

#metadata: Metadata | undefined;

/**
* @internal
*/
constructor(
data: UserProto & { modPermissions?: { [subredditName: string]: string[] } },
data: UserProtoWithAccountStatus & { modPermissions?: { [subredditName: string]: string[] } },
metadata: Metadata | undefined
) {
makeGettersEnumerable(this);
Expand Down Expand Up @@ -264,6 +306,7 @@ export class User {

this.#displayName = data.subreddit?.title ?? this.#username;
this.#about = data.subreddit?.publicDescription ?? '';
this.#accountStatus = normalizeUserAccountStatus(data);

this.#metadata = metadata;
}
Expand Down Expand Up @@ -376,7 +419,32 @@ export class User {
return this.#about;
}

toJSON(): Pick<User, 'id' | 'username' | 'createdAt' | 'linkKarma' | 'commentKarma' | 'nsfw'> & {
/**
* The user's account availability status, when provided by the Reddit API.
*
* `unknown` means the current API response did not include enough information to distinguish the
* account from an active account.
*/
get accountStatus(): UserAccountStatus {
return this.#accountStatus;
}

/** Whether the user's account is locked for account-access or security reasons. */
get isAccountLocked(): boolean {
return this.#accountStatus === 'locked';
}

toJSON(): Pick<
User,
| 'id'
| 'username'
| 'createdAt'
| 'linkKarma'
| 'commentKarma'
| 'nsfw'
| 'accountStatus'
| 'isAccountLocked'
> & {
modPermissionsBySubreddit: Record<string, ModeratorPermission[]>;
} {
return {
Expand All @@ -386,6 +454,8 @@ export class User {
linkKarma: this.linkKarma,
commentKarma: this.commentKarma,
nsfw: this.nsfw,
accountStatus: this.accountStatus,
isAccountLocked: this.isAccountLocked,
modPermissionsBySubreddit: Object.fromEntries(this.modPermissions),
};
}
Expand Down
32 changes: 32 additions & 0 deletions packages/public-api/src/apis/reddit/tests/user.api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,5 +318,37 @@ describe('User API', () => {

expect(user.modPermissions).toEqual(understoodPermissions);
});

test('accountStatus distinguishes locked users', () => {
const user = new User(
{
id: 'someID',
name: username,
createdUtc: Date.now(),
snoovatarSize: [1],
isAccountLocked: true,
},
api.metadata
);

expect(user.accountStatus).toBe('locked');
expect(user.isAccountLocked).toBe(true);
});

test('accountStatus falls back to suspended when lock status is unavailable', () => {
const user = new User(
{
id: 'someID',
name: username,
createdUtc: Date.now(),
snoovatarSize: [1],
isSuspended: true,
},
api.metadata
);

expect(user.accountStatus).toBe('suspended');
expect(user.isAccountLocked).toBe(false);
});
});
});
27 changes: 23 additions & 4 deletions packages/reddit/src/mocks/UserMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,23 @@ import type { Users } from '@devvit/protos/types/devvit/plugin/redditapi/users/u
import type { PluginMock } from '@devvit/shared-types/test/index.js';
import { isT2, T2 } from '@devvit/shared-types/tid.js';

import type { UserAccountStatus } from '../models/User.js';

type Username = string;

type UserAccountStatusFields = {
accountStatus?: UserAccountStatus | undefined;
isAccountLocked?: boolean | undefined;
isLocked?: boolean | undefined;
};

type UserWithAccountStatus = User & UserAccountStatusFields;

type UserDataByAccountIdsResponseWithAccountStatus =
UserDataByAccountIdsResponse_UserAccountData & UserAccountStatusFields;

type UserStore = {
users: Map<Username, User>;
users: Map<Username, UserWithAccountStatus>;
};

export class UserPluginMock implements Users {
Expand All @@ -58,7 +71,7 @@ export class UserPluginMock implements Users {
_metadata?: Metadata
): Promise<UserDataByAccountIdsResponse> {
const ids = request.ids.split(',');
const responseUsers: { [key: string]: UserDataByAccountIdsResponse_UserAccountData } = {};
const responseUsers: { [key: string]: UserDataByAccountIdsResponseWithAccountStatus } = {};

for (const id of ids) {
const targetId = T2(isT2(id) ? id : `t2_${id}`);
Expand All @@ -78,6 +91,8 @@ export class UserPluginMock implements Users {
linkKarma: user.linkKarma,
commentKarma: user.commentKarma,
profileOver18: user.over18,
accountStatus: user.accountStatus,
isAccountLocked: user.isAccountLocked ?? user.isLocked,
};
}
}
Expand Down Expand Up @@ -200,8 +215,10 @@ export class UserMock implements PluginMock<Users> {
* Seeds the mock database with a User.
* This allows tests to set up state before calling `reddit.getUserByUsername`.
*/
addUser(data: Omit<Partial<User>, 'id'> & { name: string; id: T2 }): User {
const user: User = {
addUser(
data: Omit<Partial<UserWithAccountStatus>, 'id'> & { name: string; id: T2 }
): UserWithAccountStatus {
const user: UserWithAccountStatus = {
createdUtc: data.createdUtc ?? Math.floor(Date.now() / 1000),
linkKarma: data.linkKarma ?? 0,
commentKarma: data.commentKarma ?? 0,
Expand All @@ -221,6 +238,8 @@ export class UserMock implements PluginMock<Users> {
prefShowSnoovatar: data.prefShowSnoovatar ?? true,
snoovatarSize: data.snoovatarSize ?? [],
...data,
accountStatus: data.accountStatus,
isAccountLocked: data.isAccountLocked ?? data.isLocked ?? false,
id: data.id.replace(/^t2_/, ''),
};

Expand Down
78 changes: 76 additions & 2 deletions packages/reddit/src/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,47 @@ export type ModeratorPermission =
| 'channels'
| 'community_chat';

export type UserAccountStatus = 'active' | 'locked' | 'suspended' | 'unknown';

type UserAccountStatusData = {
accountStatus?: UserAccountStatus | string | undefined;
isAccountLocked?: boolean | null | undefined;
isLocked?: boolean | null | undefined;
isSuspended?: boolean | null | undefined;
};

type UserProtoWithAccountStatus = UserProto & UserAccountStatusData;

function normalizeUserAccountStatus(data: UserAccountStatusData): UserAccountStatus {
const accountStatus =
typeof data.accountStatus === 'string'
? data.accountStatus.toLowerCase().replace(/^user_account_status_/, '')
: undefined;

if (
accountStatus === 'active' ||
accountStatus === 'locked' ||
accountStatus === 'suspended' ||
accountStatus === 'unknown'
) {
return accountStatus;
}

if (data.isAccountLocked === true || data.isLocked === true) {
return 'locked';
}

if (data.isSuspended === true) {
return 'suspended';
}

if (data.isAccountLocked === false || data.isLocked === false || data.isSuspended === false) {
return 'active';
}

return 'unknown';
}

export type CreateRelationshipOptions = {
subredditName: string;
username: string;
Expand Down Expand Up @@ -231,11 +272,12 @@ export class User {
#hasVerifiedEmail: boolean;
#displayName: string;
#about: string;
#accountStatus: UserAccountStatus;

/**
* @internal
*/
constructor(data: UserProto & { modPermissions?: { [subredditName: string]: string[] } }) {
constructor(data: UserProtoWithAccountStatus & { modPermissions?: { [subredditName: string]: string[] } }) {
makeGettersEnumerable(this);

assertNonNull(data.id, 'User ID is missing or undefined');
Expand Down Expand Up @@ -270,6 +312,7 @@ export class User {

this.#displayName = data.subreddit?.title ?? this.#username;
this.#about = data.subreddit?.publicDescription ?? '';
this.#accountStatus = normalizeUserAccountStatus(data);
}

/**
Expand Down Expand Up @@ -378,7 +421,32 @@ export class User {
return this.#about;
}

toJSON(): Pick<User, 'id' | 'username' | 'createdAt' | 'linkKarma' | 'commentKarma' | 'nsfw'> & {
/**
* The user's account availability status, when provided by the Reddit API.
*
* `unknown` means the current API response did not include enough information to distinguish the
* account from an active account.
*/
get accountStatus(): UserAccountStatus {
return this.#accountStatus;
}

/** Whether the user's account is locked for account-access or security reasons. */
get isAccountLocked(): boolean {
return this.#accountStatus === 'locked';
}

toJSON(): Pick<
User,
| 'id'
| 'username'
| 'createdAt'
| 'linkKarma'
| 'commentKarma'
| 'nsfw'
| 'accountStatus'
| 'isAccountLocked'
> & {
modPermissionsBySubreddit: Record<string, ModeratorPermission[]>;
} {
return {
Expand All @@ -388,6 +456,8 @@ export class User {
linkKarma: this.linkKarma,
commentKarma: this.commentKarma,
nsfw: this.nsfw,
accountStatus: this.accountStatus,
isAccountLocked: this.isAccountLocked,
modPermissionsBySubreddit: Object.fromEntries(this.modPermissions),
};
}
Expand Down Expand Up @@ -774,13 +844,17 @@ async function listingProtosToUsers(
// because of how we defined the UserDataByAccountIdsResponse_UserAccountData protobuf.
assertNonNull(userData as unknown, 'User data is missing from response');

const userAccountStatusData = userData as UserAccountStatusData;

return new User({
id,
name: userData.name,
linkKarma: userData.linkKarma,
commentKarma: userData.commentKarma,
createdUtc: userData.createdUtc,
over18: userData.profileOver18,
accountStatus: userAccountStatusData.accountStatus,
isAccountLocked: userAccountStatusData.isAccountLocked ?? userAccountStatusData.isLocked,
snoovatarSize: [],
modPermissions: {
[subredditName]: child.data?.modPermissions ?? [],
Expand Down
26 changes: 26 additions & 0 deletions packages/reddit/src/tests/user.api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,5 +208,31 @@ describe('User API', () => {

expect(user.modPermissions).toStrictEqual(understoodPermissions);
});

test('accountStatus distinguishes locked users', () => {
const user = new User({
id: 'someID',
name: username,
createdUtc: Date.now(),
snoovatarSize: [1],
isAccountLocked: true,
});

expect(user.accountStatus).toBe('locked');
expect(user.isAccountLocked).toBe(true);
});

test('accountStatus falls back to suspended when lock status is unavailable', () => {
const user = new User({
id: 'someID',
name: username,
createdUtc: Date.now(),
snoovatarSize: [1],
isSuspended: true,
});

expect(user.accountStatus).toBe('suspended');
expect(user.isAccountLocked).toBe(false);
});
});
});
Loading