Compare commits

...

3 Commits

Author SHA1 Message Date
Half-Shot
35c19592af profilely stuff 2025-01-30 09:48:05 +00:00
Half-Shot
db2c5ce26c Merge remote-tracking branch 'origin/develop' into hs/persistent-status 2025-01-23 11:01:12 +00:00
Half-Shot
3fe5392588 Initial cut. 2025-01-23 10:20:40 +00:00
14 changed files with 266 additions and 11 deletions

View File

@@ -19,6 +19,11 @@ Please see LICENSE files in the repository root for full details.
width: 100%; width: 100%;
gap: 0; gap: 0;
} }
.mx_UserProfileSettings_profile_statusMessage {
flex-grow: 1;
width: 100%;
gap: 0;
}
} }
.mx_UserProfileSettings_profile_controls { .mx_UserProfileSettings_profile_controls {

View File

@@ -54,6 +54,7 @@ import { deop, op } from "./slash-commands/op";
import { CommandCategories } from "./slash-commands/interface"; import { CommandCategories } from "./slash-commands/interface";
import { Command } from "./slash-commands/command"; import { Command } from "./slash-commands/command";
import { goto, join } from "./slash-commands/join"; import { goto, join } from "./slash-commands/join";
import { SdkContextClass } from "./contexts/SDKContext";
export { CommandCategories, Command }; export { CommandCategories, Command };
@@ -883,6 +884,25 @@ export const Commands = [
}, },
renderingTypes: [TimelineRenderingType.Room], renderingTypes: [TimelineRenderingType.Room],
}), }),
new Command({
command: "statusmsg",
description: _td("slash_command|status_msg"),
category: CommandCategories.actions,
args: "<message>",
isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, _roomId, _threadId, args) {
return success((async () => {
const supported = await cli.doesServerSupportExtendedProfiles();
if (!supported) return reject(new UserFriendlyError("slash_command|extended_profile_not_supported"));
if (!args) return reject(new UserFriendlyError("slash_command|no_status_message"));
await cli.setExtendedProfileProperty("uk.half-shot.status", args);
// Refresh profile.
void SdkContextClass.instance.userProfilesStore.fetchProfile(cli.getSafeUserId());
})());
},
renderingTypes: [TimelineRenderingType.Room],
}),
// Command definitions for autocompletion ONLY: // Command definitions for autocompletion ONLY:
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes // /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes

View File

@@ -18,6 +18,7 @@ import { RoomMember } from "../../../../models/rooms/RoomMember";
import { _t, _td, TranslationKey } from "../../../../languageHandler"; import { _t, _td, TranslationKey } from "../../../../languageHandler";
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier"; import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
import { E2EStatus } from "../../../../utils/ShieldUtils"; import { E2EStatus } from "../../../../utils/ShieldUtils";
import { useUserProfileValue } from "../../../../hooks/useUserProfileValue";
interface MemberTileViewModelProps { interface MemberTileViewModelProps {
member: RoomMember; member: RoomMember;
@@ -30,6 +31,7 @@ export interface MemberTileViewState extends MemberTileViewModelProps {
onClick: () => void; onClick: () => void;
title?: string; title?: string;
userLabel?: string; userLabel?: string;
statusMessage?: string;
} }
export enum PowerStatus { export enum PowerStatus {
@@ -44,10 +46,10 @@ const PowerLabel: Record<PowerStatus, TranslationKey> = {
export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberTileViewState { export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberTileViewState {
const [e2eStatus, setE2eStatus] = useState<E2EStatus | undefined>(); const [e2eStatus, setE2eStatus] = useState<E2EStatus | undefined>();
const cli = MatrixClientPeg.safeGet();
const statusMessage = useUserProfileValue(cli, "uk.half-shot.status", props.member.userId);
useEffect(() => { useEffect(() => {
const cli = MatrixClientPeg.safeGet();
const updateE2EStatus = async (): Promise<void> => { const updateE2EStatus = async (): Promise<void> => {
const { userId } = props.member; const { userId } = props.member;
const isMe = userId === cli.getUserId(); const isMe = userId === cli.getUserId();
@@ -103,7 +105,7 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT
cli.removeListener(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged); cli.removeListener(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged);
} }
}; };
}, [props.member]); }, [props.member, cli]);
const onClick = (): void => { const onClick = (): void => {
dis.dispatch({ dis.dispatch({
@@ -156,5 +158,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT
e2eStatus, e2eStatus,
showPresence: props.showPresence, showPresence: props.showPresence,
userLabel, userLabel,
statusMessage: statusMessage ?? undefined,
}; };
} }

View File

@@ -28,11 +28,12 @@ interface IProps {
colored?: boolean; colored?: boolean;
emphasizeDisplayName?: boolean; emphasizeDisplayName?: boolean;
withTooltip?: boolean; withTooltip?: boolean;
statusMessage?: string;
} }
export default class DisambiguatedProfile extends React.Component<IProps> { export default class DisambiguatedProfile extends React.Component<IProps> {
public render(): React.ReactNode { public render(): React.ReactNode {
const { fallbackName, member, colored, emphasizeDisplayName, withTooltip, onClick } = this.props; const { fallbackName, member, colored, emphasizeDisplayName, withTooltip, onClick, statusMessage } = this.props;
const rawDisplayName = member?.rawDisplayName || fallbackName; const rawDisplayName = member?.rawDisplayName || fallbackName;
const mxid = member?.userId; const mxid = member?.userId;
@@ -42,6 +43,7 @@ export default class DisambiguatedProfile extends React.Component<IProps> {
} }
let mxidElement; let mxidElement;
let statusElement;
let title: string | undefined; let title: string | undefined;
if (mxid) { if (mxid) {
@@ -59,6 +61,10 @@ export default class DisambiguatedProfile extends React.Component<IProps> {
}); });
} }
if (statusMessage) {
statusElement = <span className="mx_DisambiguatedProfile_mxid">({statusMessage})</span>;
}
const displayNameClasses = classNames(colorClass, { const displayNameClasses = classNames(colorClass, {
mx_DisambiguatedProfile_displayName: emphasizeDisplayName, mx_DisambiguatedProfile_displayName: emphasizeDisplayName,
}); });
@@ -69,6 +75,7 @@ export default class DisambiguatedProfile extends React.Component<IProps> {
{rawDisplayName} {rawDisplayName}
</span> </span>
{mxidElement} {mxidElement}
{statusElement}
</div> </div>
); );
} }

View File

@@ -12,6 +12,8 @@ import { MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
import DisambiguatedProfile from "./DisambiguatedProfile"; import DisambiguatedProfile from "./DisambiguatedProfile";
import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile"; import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile";
import { useUserProfileValue } from "../../../hooks/useUserProfileValue";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@@ -24,9 +26,11 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
userId: mxEvent.getSender(), userId: mxEvent.getSender(),
member: mxEvent.sender, member: mxEvent.sender,
}); });
const statusMessage = useUserProfileValue(MatrixClientPeg.safeGet(), "uk.half-shot.status", mxEvent.sender?.userId);
return mxEvent.getContent().msgtype !== MsgType.Emote ? ( return mxEvent.getContent().msgtype !== MsgType.Emote ? (
<DisambiguatedProfile <DisambiguatedProfile
statusMessage={statusMessage ?? undefined}
fallbackName={mxEvent.getSender() ?? ""} fallbackName={mxEvent.getSender() ?? ""}
onClick={onClick} onClick={onClick}
member={member} member={member}

View File

@@ -85,6 +85,7 @@ import { asyncSome } from "../../../utils/arrays";
import { Flex } from "../../utils/Flex"; import { Flex } from "../../utils/Flex";
import CopyableText from "../elements/CopyableText"; import CopyableText from "../elements/CopyableText";
import { useUserTimezone } from "../../../hooks/useUserTimezone"; import { useUserTimezone } from "../../../hooks/useUserTimezone";
import { useUserProfileValue } from "../../../hooks/useUserProfileValue";
export interface IDevice extends Device { export interface IDevice extends Device {
ambiguous?: boolean; ambiguous?: boolean;
} }
@@ -1634,6 +1635,8 @@ export const UserInfoHeader: React.FC<{
roomId?: string; roomId?: string;
}> = ({ member, e2eStatus, roomId }) => { }> = ({ member, e2eStatus, roomId }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
console.log(cli, member);
const statusMessage = useUserProfileValue(cli, "uk.half-shot.status", member.userId, true);
const onMemberAvatarClick = useCallback(() => { const onMemberAvatarClick = useCallback(() => {
const avatarUrl = (member as RoomMember).getMxcAvatarUrl const avatarUrl = (member as RoomMember).getMxcAvatarUrl
@@ -1667,6 +1670,7 @@ export const UserInfoHeader: React.FC<{
if (enablePresenceByHsUrl && enablePresenceByHsUrl[cli.baseUrl] !== undefined) { if (enablePresenceByHsUrl && enablePresenceByHsUrl[cli.baseUrl] !== undefined) {
showPresence = enablePresenceByHsUrl[cli.baseUrl]; showPresence = enablePresenceByHsUrl[cli.baseUrl];
} }
let presenceLabel: JSX.Element | undefined; let presenceLabel: JSX.Element | undefined;
if (showPresence) { if (showPresence) {
@@ -1675,10 +1679,13 @@ export const UserInfoHeader: React.FC<{
activeAgo={presenceLastActiveAgo} activeAgo={presenceLastActiveAgo}
currentlyActive={presenceCurrentlyActive} currentlyActive={presenceCurrentlyActive}
presenceState={presenceState} presenceState={presenceState}
statusMessage={statusMessage ?? undefined}
className="mx_UserInfo_profileStatus" className="mx_UserInfo_profileStatus"
coloured coloured
/> />
); );
} else if (statusMessage) {
presenceLabel = <span>{statusMessage}</span>
} }
const timezoneInfo = useUserTimezone(cli, member.userId); const timezoneInfo = useUserTimezone(cli, member.userId);

View File

@@ -19,9 +19,10 @@ interface IEmojiButtonProps {
addEmoji: (unicode: string) => boolean; addEmoji: (unicode: string) => boolean;
menuPosition?: MenuProps; menuPosition?: MenuProps;
className?: string; className?: string;
single?: boolean;
} }
export function EmojiButton({ addEmoji, menuPosition, className }: IEmojiButtonProps): JSX.Element { export function EmojiButton({ addEmoji, menuPosition, className, single }: IEmojiButtonProps): JSX.Element {
const overflowMenuCloser = useContext(OverflowMenuContext); const overflowMenuCloser = useContext(OverflowMenuContext);
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@@ -32,10 +33,19 @@ export function EmojiButton({ addEmoji, menuPosition, className }: IEmojiButtonP
closeMenu(); closeMenu();
overflowMenuCloser?.(); overflowMenuCloser?.();
}; };
const onChoose = (emoji: string) => {
try {
return addEmoji(emoji);
} finally {
if (single) {
onFinished();
}
}
};
contextMenu = ( contextMenu = (
<ContextMenu {...position} onFinished={onFinished} managed={false}> <ContextMenu {...position} onFinished={onFinished} managed={false}>
<EmojiPicker onChoose={addEmoji} onFinished={onFinished} /> <EmojiPicker onChoose={onChoose} onFinished={onFinished} />
</ContextMenu> </ContextMenu>
); );
} }

View File

@@ -36,7 +36,7 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
/> />
); );
const name = vm.name; const name = vm.name;
const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />; const nameJSX = <DisambiguatedProfile statusMessage={vm.statusMessage} member={member} fallbackName={name || ""} />;
const presenceState = member.presenceState; const presenceState = member.presenceState;
let presenceJSX: JSX.Element | undefined; let presenceJSX: JSX.Element | undefined;

View File

@@ -27,6 +27,7 @@ interface IProps {
// whether to apply colouring to the label // whether to apply colouring to the label
coloured?: boolean; coloured?: boolean;
className?: string; className?: string;
statusMessage?: string;
} }
export default class PresenceLabel extends React.Component<IProps> { export default class PresenceLabel extends React.Component<IProps> {
@@ -57,7 +58,7 @@ export default class PresenceLabel extends React.Component<IProps> {
} }
public render(): React.ReactNode { public render(): React.ReactNode {
return ( return <>
<div <div
className={classNames("mx_PresenceLabel", this.props.className, { className={classNames("mx_PresenceLabel", this.props.className, {
mx_PresenceLabel_online: this.props.coloured && this.props.presenceState === "online", mx_PresenceLabel_online: this.props.coloured && this.props.presenceState === "online",
@@ -65,6 +66,8 @@ export default class PresenceLabel extends React.Component<IProps> {
> >
{this.getPrettyPresence(this.props.presenceState, this.props.activeAgo, this.props.currentlyActive)} {this.getPrettyPresence(this.props.presenceState, this.props.activeAgo, this.props.currentlyActive)}
</div> </div>
);
<p>{this.props.statusMessage}</p>
</>;
} }
} }

View File

@@ -27,6 +27,9 @@ import LogoutDialog, { shouldShowLogoutDialog } from "../dialogs/LogoutDialog";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Flex } from "../../utils/Flex"; import { Flex } from "../../utils/Flex";
import { useUserProfileValue } from "../../../hooks/useUserProfileValue";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { EmojiButton } from "../rooms/EmojiButton";
const SpinnerToast: React.FC<{ children?: ReactNode }> = ({ children }) => ( const SpinnerToast: React.FC<{ children?: ReactNode }> = ({ children }) => (
<> <>
@@ -114,9 +117,21 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({
const [maxUploadSize, setMaxUploadSize] = useState<number | undefined>(); const [maxUploadSize, setMaxUploadSize] = useState<number | undefined>();
const [displayNameError, setDisplayNameError] = useState<boolean>(false); const [displayNameError, setDisplayNameError] = useState<boolean>(false);
const [statusMessageSupported, setStatusMessageSupported] = useState<boolean>(false);
const [statusMessageError, setStatusMessageError] = useState<boolean>(false);
const toastRack = useToastContext(); const toastRack = useToastContext();
const client = useMatrixClientContext(); const client = useMatrixClientContext();
const currentStatusMessage = useUserProfileValue(client, "uk.half-shot.status", client.getSafeUserId(), true);
const [statusMessage, setStatusMessage] = useState<string>(currentStatusMessage ?? "");
useEffect(() => {
if (currentStatusMessage) {
setStatusMessage(currentStatusMessage);
}
}, [currentStatusMessage]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -129,6 +144,13 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({
})(); })();
}, [client]); }, [client]);
useEffect(() => {
(async () => {
const supported = await client.doesServerSupportExtendedProfiles();
setStatusMessageSupported(supported);
})();
}, [client]);
const onAvatarRemove = useCallback(async () => { const onAvatarRemove = useCallback(async () => {
const removeToast = toastRack.displayToast( const removeToast = toastRack.displayToast(
<SpinnerToast>{_t("settings|general|avatar_remove_progress")}</SpinnerToast>, <SpinnerToast>{_t("settings|general|avatar_remove_progress")}</SpinnerToast>,
@@ -190,6 +212,31 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({
[client], [client],
); );
const onStatusChanged = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setStatusMessage(e.target.value);
}, []);
const onStatusAddEmoji = useCallback((emoij: string) => {
setStatusMessage(s => s + emoij);
return true;
}, []);
const onStatusCancelled = useCallback(() => {
setStatusMessage(currentStatusMessage ?? "");
}, [currentStatusMessage]);
const onStatusSave = useCallback(async (): Promise<void> => {
try {
setStatusMessageError(false);
await client.setExtendedProfileProperty("uk.half-shot.status", statusMessage);
// Force profile to be refetched.
SdkContextClass.instance.userProfilesStore.fetchProfile(client.getSafeUserId());
} catch (e) {
setStatusMessageError(true);
throw e;
}
}, [statusMessage, client]);
const someFieldsDisabled = !canSetDisplayName || !canSetAvatar; const someFieldsDisabled = !canSetDisplayName || !canSetAvatar;
return ( return (
@@ -225,6 +272,22 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({
> >
{displayNameError && <ErrorMessage>{_t("settings|general|display_name_error")}</ErrorMessage>} {displayNameError && <ErrorMessage>{_t("settings|general|display_name_error")}</ErrorMessage>}
</EditInPlace> </EditInPlace>
<EditInPlace
className="mx_UserProfileSettings_profile_statusMessage"
label={_t("settings|general|status_message")}
value={statusMessage}
saveButtonLabel={_t("common|save")}
cancelButtonLabel={_t("common|cancel")}
savedLabel={_t("common|saved")}
savingLabel={_t("common|updating")}
onChange={onStatusChanged}
onCancel={onStatusCancelled}
onSave={onStatusSave}
disabled={!statusMessageSupported}
>
{statusMessageError && <ErrorMessage>{_t("settings|general|status_message_error")}</ErrorMessage>}
</EditInPlace>
{statusMessageSupported ? <EmojiButton single addEmoji={onStatusAddEmoji}/> : null}
</div> </div>
{avatarError && ( {avatarError && (
<Alert title={_t("settings|general|avatar_upload_error_title")} type="critical"> <Alert title={_t("settings|general|avatar_upload_error_title")} type="critical">

View File

@@ -0,0 +1,77 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { useEffect, useState } from "react";
import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { SdkContextClass } from "../contexts/SDKContext";
import { UserProfilesStoreEvents } from "../stores/UserProfilesStore";
import { useTypedEventEmitter } from "./useEventEmitter";
/**
* Fetch a key from a user's extended profile, and regularly refetch at a
* given interval.
*
* @param cli The Matrix Client instance.
* @param key The key to fetch.
* @param userId The user who's profile we're interested in.
* @param ignoreCache Ignore the cache on first load.
* @returns The value from the profile, or null if not set.
*/
export const useUserProfileValue = (cli: MatrixClient, key: string, userId?: string, ignoreCache?: boolean): string|null => {
const [currentValue, setProfileValue] = useState<string|null>(null);
const profilesStore = SdkContextClass.instance.userProfilesStore;
useEffect(() => {
if (!userId) {
return;
}
profilesStore.subscribeToProfile(userId);
return () => profilesStore.unsubscribeToProfile(userId);
}, [userId]);
useTypedEventEmitter(profilesStore, UserProfilesStoreEvents.ProfileUpdated, (updatedUserId, updatedProfile) => {
if (userId !== updatedUserId) {
return;
}
const value = (updatedProfile as any)?.[key];
if (!value) {
return;
}
if (typeof value !== "string") {
// Err, definitely not a tz.
throw Error("Profile value was not a string");
}
setProfileValue(value);
});
useEffect(() => {
if (!userId) {
return;
}
(async () => {
try {
const profile = await (ignoreCache ? profilesStore.fetchProfile(userId) : profilesStore.getOrFetchProfile(userId));
// TODO: Types.
const value = (profile as any)?.[key];
if (!value) {
return;
}
if (typeof value !== "string") {
// Err, definitely not a tz.
throw Error("Profile value was not a string");
}
setProfileValue(value);
} catch (ex) {
if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") {
// No timezone set, ignore.
setProfileValue(null);
return;
}
}
})();
}, [userId, key, cli]);
return currentValue;
};

View File

@@ -48,7 +48,6 @@ export const useUserTimezone = (cli: MatrixClient, userId: string): { timezone:
return; return;
} }
(async () => { (async () => {
console.log("Trying to fetch TZ");
try { try {
const tz = await cli.getExtendedProfileProperty(userId, "us.cloke.msc4175.tz"); const tz = await cli.getExtendedProfileProperty(userId, "us.cloke.msc4175.tz");
if (typeof tz !== "string") { if (typeof tz !== "string") {

View File

@@ -2532,6 +2532,8 @@
"remove_email_prompt": "Remove %(email)s?", "remove_email_prompt": "Remove %(email)s?",
"remove_msisdn_prompt": "Remove %(phone)s?", "remove_msisdn_prompt": "Remove %(phone)s?",
"spell_check_locale_placeholder": "Choose a locale", "spell_check_locale_placeholder": "Choose a locale",
"status_message": "Current status",
"status_message_error": "Failed to set status message",
"unable_to_load_emails": "Unable to load email addresses", "unable_to_load_emails": "Unable to load email addresses",
"unable_to_load_msisdns": "Unable to load phone numbers", "unable_to_load_msisdns": "Unable to load phone numbers",
"username": "Username" "username": "Username"
@@ -2961,6 +2963,7 @@
"error_invalid_room": "Command failed: Unable to find room (%(roomId)s)", "error_invalid_room": "Command failed: Unable to find room (%(roomId)s)",
"error_invalid_runfn": "Command error: Unable to handle slash command.", "error_invalid_runfn": "Command error: Unable to handle slash command.",
"error_invalid_user_in_room": "Could not find user in room", "error_invalid_user_in_room": "Could not find user in room",
"extended_profile_not_supported": "Status messages are not supported on this server",
"help": "Displays list of commands with usages and descriptions", "help": "Displays list of commands with usages and descriptions",
"help_dialog_title": "Command Help", "help_dialog_title": "Command Help",
"holdcall": "Places the call in the current room on hold", "holdcall": "Places the call in the current room on hold",
@@ -2984,6 +2987,7 @@
"myroomnick": "Changes your display nickname in the current room only", "myroomnick": "Changes your display nickname in the current room only",
"nick": "Changes your display nickname", "nick": "Changes your display nickname",
"no_active_call": "No active call in this room", "no_active_call": "No active call in this room",
"no_status_message": "You need to provide a status message",
"op": "Define the power level of a user", "op": "Define the power level of a user",
"part_unknown_alias": "Unrecognised room address: %(roomAlias)s", "part_unknown_alias": "Unrecognised room address: %(roomAlias)s",
"plain": "Sends a message as plain text, without interpreting it as markdown", "plain": "Sends a message as plain text, without interpreting it as markdown",
@@ -2999,6 +3003,7 @@
"server_error_detail": "Server unavailable, overloaded, or something else went wrong.", "server_error_detail": "Server unavailable, overloaded, or something else went wrong.",
"shrug": "Prepends ¯\\_(ツ)_/¯ to a plain-text message", "shrug": "Prepends ¯\\_(ツ)_/¯ to a plain-text message",
"spoiler": "Sends the given message as a spoiler", "spoiler": "Sends the given message as a spoiler",
"status_msg": "Update your status message in your profile",
"tableflip": "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message", "tableflip": "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message",
"topic": "Gets or sets the room topic", "topic": "Gets or sets the room topic",
"topic_none": "This room has no topic.", "topic_none": "This room has no topic.",

View File

@@ -14,6 +14,9 @@ import {
MatrixEvent, MatrixEvent,
RoomMember, RoomMember,
RoomMemberEvent, RoomMemberEvent,
SyncState,
TypedEventEmitter,
ClientEvent,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { LruCache } from "../utils/LruCache"; import { LruCache } from "../utils/LruCache";
@@ -27,19 +30,67 @@ interface GetOptions {
shouldThrow: boolean; shouldThrow: boolean;
} }
export enum UserProfilesStoreEvents {
ProfileUpdated = "profile_updated",
}
interface UserProfilesStoreEventsMap {
[UserProfilesStoreEvents.ProfileUpdated]: (userId: string, profile: IMatrixProfile|null) => void,
}
/** /**
* This store provides cached access to user profiles. * This store provides cached access to user profiles.
* Listens for membership events and invalidates the cache for a profile on update with different profile values. * Listens for membership events and invalidates the cache for a profile on update with different profile values.
*/ */
export class UserProfilesStore { export class UserProfilesStore extends TypedEventEmitter<UserProfilesStoreEvents, UserProfilesStoreEventsMap> {
private profiles = new LruCache<string, IMatrixProfile | null>(cacheSize); private profiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
private profileLookupErrors = new LruCache<string, MatrixError>(cacheSize); private profileLookupErrors = new LruCache<string, MatrixError>(cacheSize);
private knownProfiles = new LruCache<string, IMatrixProfile | null>(cacheSize); private knownProfiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
private readonly profileSubscriptions = new Map<string, {subs: number, lastSync: number}>();
public constructor(private client: MatrixClient) { public constructor(private client: MatrixClient) {
super();
client.on(RoomMemberEvent.Membership, this.onRoomMembershipEvent); client.on(RoomMemberEvent.Membership, this.onRoomMembershipEvent);
client.on(ClientEvent.Sync, (state) => {
if (state !== SyncState.Syncing) {
return;
}
const time = Date.now();
for (const [userId, entry] of this.profileSubscriptions.entries()) {
if (time - entry.lastSync < 60000) {
continue;
}
void this.fetchProfile(userId, { shouldThrow: false});
entry.lastSync = time;
}
})
} }
public subscribeToProfile(userId: string) {
const existingProfile = this.profileSubscriptions.get(userId);
console.log(`Sub for ${userId} incremented to ${(existingProfile?.subs ?? 0) + 1}`);
if (existingProfile) {
existingProfile.subs += 1;
} else {
this.profileSubscriptions.set(userId, { subs: 1, lastSync: 0})
}
console.log(`Number of subs: ${this.profileSubscriptions.size}`);
}
public unsubscribeToProfile(userId: string) {
const existingProfile = this.profileSubscriptions.get(userId);
if (!existingProfile) {
return;
}
existingProfile.subs -= 1;
console.log(`Sub for ${userId} reduced to ${existingProfile.subs}`);
if (existingProfile.subs === 0) {
this.profileSubscriptions.delete(userId);
}
}
/** /**
* Synchronously get a profile from the store cache. * Synchronously get a profile from the store cache.
* *
@@ -104,6 +155,7 @@ export class UserProfilesStore {
public async fetchProfile(userId: string, options?: GetOptions): Promise<IMatrixProfile | null> { public async fetchProfile(userId: string, options?: GetOptions): Promise<IMatrixProfile | null> {
const profile = await this.fetchProfileFromApi(userId, options); const profile = await this.fetchProfileFromApi(userId, options);
this.profiles.set(userId, profile); this.profiles.set(userId, profile);
this.emit(UserProfilesStoreEvents.ProfileUpdated, userId, profile);
return profile; return profile;
} }