Files
outline/app/models/User.ts
T
2026-06-12 21:40:11 +01:00

326 lines
8.0 KiB
TypeScript

import { subMinutes } from "date-fns";
import { computed, action, observable } from "mobx";
import { now } from "mobx-utils";
import { UserPreferenceDefaults } from "@shared/constants";
import {
NotificationChannelType,
NotificationEventDefaults,
type NotificationEventType,
TeamPreference,
UserPreference,
type UserPreferences,
UserRole,
} from "@shared/types";
import type { NotificationSettings } from "@shared/types";
import type { locales } from "@shared/utils/date";
import { client } from "~/utils/ApiClient";
import type Document from "./Document";
import type Group from "./Group";
import type UserMembership from "./UserMembership";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
import type { Searchable } from "./interfaces/Searchable";
class User extends ParanoidModel implements Searchable {
static modelName = "User";
@Field
@observable
avatarUrl: string;
@Field
@observable
name: string;
@Field
@observable
color: string;
@Field
@observable
language: keyof typeof locales;
@Field
@observable
preferences: UserPreferences | null;
@Field
@observable
notificationSettings: NotificationSettings;
@Field
@observable
timezone?: string;
@observable
email: string;
@observable
role: UserRole;
@observable
protected _lastActiveAt: string;
/**
* The last time the user was active. For the currently signed-in user, this
* always returns the current date so they always appear as recently active.
*/
@computed
get lastActiveAt(): string {
if (this.store.rootStore.auth?.currentUserId === this.id) {
return new Date(now(60000)).toISOString();
}
return this._lastActiveAt;
}
set lastActiveAt(value: string) {
this._lastActiveAt = value;
}
@observable
isSuspended: boolean;
@computed
get searchContent(): string[] {
return [this.name, this.email, this.initials].filter(Boolean);
}
@computed
get searchSuppressed(): boolean {
return this.isDeleted;
}
@computed
get initial(): string {
return (this.name ? this.name[0] : "?").toUpperCase();
}
@computed
get initials(): string {
if (!this.name) {
return "";
}
const names = this.name.trim().split(" ");
if (names.length === 1) {
return names[0][0].toUpperCase();
}
return (names[0][0] + names[names.length - 1][0]).toUpperCase();
}
/**
* Whether the user has been invited but not yet signed in.
*/
get isInvited(): boolean {
return !this.lastActiveAt;
}
/**
* Whether the user is an admin.
*/
get isAdmin(): boolean {
return this.role === UserRole.Admin;
}
/**
* Whether the user is a member (editor).
*/
get isMember(): boolean {
return this.role === UserRole.Member;
}
/**
* Whether the user is a viewer.
*/
get isViewer(): boolean {
return this.role === UserRole.Viewer;
}
/**
* Whether the user is a guest.
*/
get isGuest(): boolean {
return this.role === UserRole.Guest;
}
/**
* Whether the user has been recently active. Recently is currently defined
* as within the last 5 minutes.
*
* @returns true if the user has been active recently
*/
@computed
get isRecentlyActive(): boolean {
return new Date(this.lastActiveAt) > subMinutes(now(10000), 5);
}
/**
* Returns whether this user is using a separate editing mode behind an "Edit"
* button rather than seamless always-editing.
*
* @returns True if editing mode is seamless (no button)
*/
@computed
get separateEditMode(): boolean {
return !this.getPreference(
UserPreference.SeamlessEdit,
this.store.rootStore.auth?.team?.getPreference(
TeamPreference.SeamlessEdit
)
);
}
/**
* Returns the direct memberships that this user has to documents. Documents that the
* user already has access to through a collection, archived, and trashed documents are not included.
*
* @returns A list of user memberships
*/
@computed
get documentMemberships(): UserMembership[] {
const { userMemberships, documents, policies } = this.store.rootStore;
return userMemberships.orderedData
.filter(
(m) => m.userId === this.id && m.sourceId === null && m.documentId
)
.filter((m) => {
const document = documents.get(m.documentId!);
const policy = document?.collectionId
? policies.get(document.collectionId)
: undefined;
return !policy?.abilities?.readDocument && !!document?.isActive;
});
}
@computed
get groupsWithDocumentMemberships() {
const { groups, groupUsers } = this.store.rootStore;
return groupUsers.orderedData
.filter((groupUser) => groupUser.userId === this.id)
.map((groupUser) => groups.get(groupUser.groupId))
.filter(Boolean)
.filter((group) => group && group.documentMemberships.length > 0)
.sort((a, b) => a!.name.localeCompare(b!.name)) as Group[];
}
/**
* Returns the current preference for the given notification event type taking
* into account the default system value.
*
* @param type The type of notification event.
* @param channel Optional channel type for channel-specific check.
* @returns The current preference.
*/
public subscribedToEventType = (
type: NotificationEventType,
channel = NotificationChannelType.Email
): boolean => {
const setting = this.notificationSettings[type];
const defaultValue = NotificationEventDefaults[type] ?? false;
if (setting === undefined) {
return defaultValue;
}
if (typeof setting === "boolean") {
return setting;
}
if (typeof setting === "object") {
return setting[channel] ?? defaultValue;
}
return defaultValue;
};
/**
* Sets a preference for the users notification settings on the model and
* saves the change to the server.
*
* @param type The type of notification event.
* @param value Set the preference to true/false.
* @param channel Optional channel type for channel-specific settings.
*/
@action
setNotificationEventType = async (
eventType: NotificationEventType,
value: boolean | Record<NotificationChannelType, boolean>,
channel?: NotificationChannelType
) => {
if (channel !== undefined) {
// Setting a specific channel preference
const currentSetting = this.notificationSettings[eventType];
const channelSettings =
typeof currentSetting === "object" ? currentSetting : {};
this.notificationSettings = {
...this.notificationSettings,
[eventType]: {
...channelSettings,
[channel]: value,
},
};
} else {
// Setting all channels or simple boolean
this.notificationSettings = {
...this.notificationSettings,
[eventType]: value,
};
}
if (value) {
await client.post(`/users.notificationsSubscribe`, {
eventType,
channel,
});
} else {
await client.post(`/users.notificationsUnsubscribe`, {
eventType,
channel,
});
}
};
/**
* Get the value for a specific preference key, or return the fallback if
* none is set.
*
* @param key The UserPreference key to retrieve
* @returns The value
*/
getPreference<K extends UserPreference>(
key: K,
defaultValue?: UserPreferences[K]
): NonNullable<UserPreferences[K]> {
return (this.preferences?.[key] ??
UserPreferenceDefaults[key] ??
defaultValue ??
false) as NonNullable<UserPreferences[K]>;
}
/**
* Set the value for a specific preference key.
*
* @param key The UserPreference key to retrieve
* @param value The value to set
*/
@action
setPreference<K extends UserPreference>(
key: K,
value: NonNullable<UserPreferences[K]>
) {
this.preferences = {
...this.preferences,
[key]: value,
};
}
getMembership(document: Document) {
return this.store.rootStore.userMemberships.orderedData.find(
(m) => m.documentId === document.id && m.userId === this.id
);
}
}
export default User;