import React, { ChangeEventHandler, FormEventHandler, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { Box, Text, IconButton, Icon, Icons, Input, Avatar, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal, Dialog, Header, config, color, Spinner, PopOut, RectCords, } from 'folds'; import { Method } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import { SettingsSelect } from '../../../components/settings-select/SettingsSelect'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getAccountData, setAccountData } from '../../../utils/accountData'; import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { UserAvatar } from '../../../components/user-avatar'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { nameInitials } from '../../../utils/common'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useFilePicker } from '../../../hooks/useFilePicker'; import { useObjectURL } from '../../../hooks/useObjectURL'; import { stopPropagation } from '../../../utils/keyboard'; import { ImageEditor } from '../../../components/image-editor'; import { ModalWide } from '../../../styles/Modal.css'; import { createUploadAtom, UploadSuccess } from '../../../state/upload'; import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { useCapabilities } from '../../../hooks/useCapabilities'; import { Presence, useUserPresence } from '../../../hooks/useUserPresence'; import { ProfileDecoration } from './ProfileDecoration'; import { EmojiBoard } from '../../../components/emoji-board'; type ProfileProps = { profile: UserProfile; userId: string; }; function ProfileAvatar({ profile, userId }: ProfileProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const capabilities = useCapabilities(); const [alertRemove, setAlertRemove] = useState(false); const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false; const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const avatarUrl = profile.avatarUrl ? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined; const [imageFile, setImageFile] = useState(); const imageFileURL = useObjectURL(imageFile); const uploadAtom = useMemo(() => { if (imageFile) return createUploadAtom(imageFile); return undefined; }, [imageFile]); const pickFile = useFilePicker(setImageFile, false); const handleRemoveUpload = useCallback(() => { setImageFile(undefined); }, []); const handleUploaded = useCallback( (upload: UploadSuccess) => { const { mxc } = upload; mx.setAvatarUrl(mxc); handleRemoveUpload(); }, [mx, handleRemoveUpload], ); const handleRemoveAvatar = () => { mx.setAvatarUrl(''); setAlertRemove(false); }; return ( Avatar } after={ {nameInitials(defaultDisplayName)}} /> } > {uploadAtom ? ( ) : ( {avatarUrl && ( )} )} {imageFileURL && ( }> )} }> setAlertRemove(false), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} >
Remove Avatar setAlertRemove(false)} radii="300" aria-label="Cancel" >
Are you sure you want to remove profile avatar?
); } function ProfileDisplayName({ profile, userId }: ProfileProps) { const mx = useMatrixClient(); const capabilities = useCapabilities(); const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false; const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const [displayName, setDisplayName] = useState(defaultDisplayName); const [changeState, changeDisplayName] = useAsyncCallback( useCallback((name: string) => mx.setDisplayName(name), [mx]), ); const changingDisplayName = changeState.status === AsyncStatus.Loading; useEffect(() => { setDisplayName(defaultDisplayName); }, [defaultDisplayName]); const handleChange: ChangeEventHandler = (evt) => { const name = evt.currentTarget.value; setDisplayName(name); }; const handleReset = () => { setDisplayName(defaultDisplayName); }; const handleSubmit: FormEventHandler = (evt) => { evt.preventDefault(); if (changingDisplayName) return; const target = evt.target as HTMLFormElement | undefined; const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined; const name = displayNameInput?.value; if (!name) return; changeDisplayName(name); }; const hasChanges = displayName !== defaultDisplayName; return ( Display Name } > ) } /> ); } export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`; export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`; const CLEAR_AFTER_OPTIONS = [ { label: 'Never', value: '0' }, { label: '30 minutes', value: String(30 * 60 * 1000) }, { label: '1 hour', value: String(60 * 60 * 1000) }, { label: '4 hours', value: String(4 * 60 * 60 * 1000) }, { label: '8 hours', value: String(8 * 60 * 60 * 1000) }, { label: 'Until midnight', value: 'today' }, { label: '1 day', value: String(24 * 60 * 60 * 1000) }, { label: '7 days', value: String(7 * 24 * 60 * 60 * 1000) }, ]; function getMsFromOption(value: string): number { if (value === '0') return 0; if (value === 'today') { const eod = new Date(); eod.setHours(23, 59, 59, 999); return eod.getTime() - Date.now(); } return parseInt(value, 10); } function ProfileStatus() { const mx = useMatrixClient(); const userId = mx.getUserId()!; const presence = useUserPresence(userId); const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus'); const [hidePresence] = useSetting(settingsAtom, 'hidePresence'); const [statusMsg, setStatusMsg] = useState( presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '', ); // True while the user has unsaved local edits — prevents a server presence // echo from overwriting what the user is currently typing/inserting. const statusDirtyRef = useRef(false); const [clearAfter, setClearAfter] = useState('0'); const [emojiAnchor, setEmojiAnchor] = useState(); // Sync input when another device changes the status. // Skipped while the user has unsaved local edits to avoid clobbering // mid-flight input (e.g. an emoji being inserted), and while presence data has // not loaded yet (presence === undefined is "no info", NOT a clear). useEffect(() => { if (statusDirtyRef.current || !presence) return; // An offline/invisible presence carries an empty status_msg by design (see // usePresenceUpdater.setOffline), so ignore it — reading it as a clear would // wipe the saved status on every invisible toggle. if (presence.presence === Presence.Offline) return; const remoteStatus = presence.status ?? ''; if (remoteStatus) { setStatusMsg(remoteStatus); localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus); } else { // Another device CLEARED the status. Mirror it locally AND drop the stored // value, otherwise usePresenceUpdater.readStatus() re-sends the stale // message on the next presence heartbeat. setStatusMsg(''); localStorage.removeItem(STATUS_MSG_KEY(userId)); } }, [presence, userId]); const [saveState, saveStatus] = useAsyncCallback( useCallback( (msg: string) => mx.setPresence({ // Derive presence from the user's chosen setting so writing a status // never overrides Invisible/DND/Idle (e.g. outing an Invisible user). presence: presenceStateFromSetting(presenceStatus, hidePresence), status_msg: msg, }), [mx, presenceStatus, hidePresence], ), ); const saving = saveState.status === AsyncStatus.Loading; const handleEmojiSelect = useCallback((unicode: string) => { statusDirtyRef.current = true; setStatusMsg((prev) => prev + unicode); setEmojiAnchor(undefined); }, []); const handleChange: ChangeEventHandler = (evt) => { statusDirtyRef.current = true; setStatusMsg(evt.currentTarget.value); }; const handleSubmit: FormEventHandler = (evt) => { evt.preventDefault(); if (saving) return; statusDirtyRef.current = false; const msg = statusMsg.trim(); saveStatus(msg).catch(() => undefined); if (msg) { localStorage.setItem(STATUS_MSG_KEY(userId), msg); } else { localStorage.removeItem(STATUS_MSG_KEY(userId)); } const delayMs = getMsFromOption(clearAfter); if (msg && delayMs > 0) { // Persist the expiry timestamp; the always-mounted StatusExpiryMonitor // (ClientNonUIFeatures) fires the auto-clear even when Settings is closed. const ts = Date.now() + delayMs; localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts)); } else { localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); } }; const handleClear = () => { statusDirtyRef.current = false; setStatusMsg(''); localStorage.removeItem(STATUS_MSG_KEY(userId)); localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); // Preserve the user's chosen presence when clearing the status message. mx.setPresence({ presence: presenceStateFromSetting(presenceStatus, hidePresence), status_msg: '', }).catch(() => undefined); }; const hasChanges = statusMsg !== (presence?.status ?? ''); return ( Status Message } description={ Shown below your name in member lists. Supports emoji. } > = 56 ? 1 : 0.45, color: statusMsg.length >= 64 ? color.Critical.Main : statusMsg.length >= 56 ? color.Warning.Main : undefined, }} > {statusMsg.length} / 64 setEmojiAnchor(undefined)} /> } > ) => { const rect = evt.currentTarget.getBoundingClientRect(); setEmojiAnchor((prev) => (prev ? undefined : rect)); }} > {saveState.status === AsyncStatus.Error && ( Failed to save status — server may be rate limiting. Try again. )} Auto-clear after: {(presence?.status || statusMsg) && ( )} ); } const COMMON_TIMEZONES = [ 'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Toronto', 'America/Vancouver', 'America/Sao_Paulo', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Moscow', 'Africa/Cairo', 'Asia/Dubai', 'Asia/Kolkata', 'Asia/Singapore', 'Asia/Tokyo', 'Asia/Shanghai', 'Australia/Sydney', 'Pacific/Auckland', ]; function ProfilePronouns() { const mx = useMatrixClient(); const userId = mx.getUserId()!; const [pronouns, setPronouns] = useState(''); const [savedPronouns, setSavedPronouns] = useState(''); useEffect(() => { mx.http .authedRequest<{ 'm.pronouns': string }>( Method.Get, `/profile/${encodeURIComponent(userId)}/m.pronouns`, ) .then((res) => { const val = res['m.pronouns'] ?? ''; setPronouns(val); setSavedPronouns(val); }) .catch(() => { setPronouns(''); setSavedPronouns(''); }); }, [mx, userId]); const [saveState, savePronouns] = useAsyncCallback( useCallback( (value: string) => mx.http .authedRequest( Method.Put, `/profile/${encodeURIComponent(userId)}/m.pronouns`, undefined, { 'm.pronouns': value }, ) .then(() => { setSavedPronouns(value); }), [mx, userId], ), ); const saving = saveState.status === AsyncStatus.Loading; const handleChange: ChangeEventHandler = (evt) => { setPronouns(evt.currentTarget.value); }; const handleReset = () => { setPronouns(savedPronouns); }; const handleSubmit: FormEventHandler = (evt) => { evt.preventDefault(); if (saving) return; savePronouns(pronouns.trim()); }; const hasChanges = pronouns !== savedPronouns; return ( Pronouns } description={ Shown on your profile. Visible to other users. } > ) } /> {saveState.status === AsyncStatus.Error && ( Failed to save pronouns. Try again. )} ); } function ProfileTimezone() { const mx = useMatrixClient(); const userId = mx.getUserId()!; const [timezone, setTimezone] = useState(''); const [savedTimezone, setSavedTimezone] = useState(''); useEffect(() => { const cached = getAccountData<{ timezone: string }>(mx, 'im.lotus.timezone'); if (cached?.timezone) { setTimezone(cached.timezone); setSavedTimezone(cached.timezone); } // Also fetch from server in case account data hasn't synced yet mx.http .authedRequest<{ timezone: string }>( Method.Get, `/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`, ) .then((res) => { const val = res.timezone ?? ''; setTimezone(val); setSavedTimezone(val); }) .catch(() => { /* no stored timezone yet */ }); }, [mx, userId]); const [saveState, saveTimezone] = useAsyncCallback( useCallback( (value: string) => Promise.all([ // Self-fallback: account data is readable by useExtendedProfile for the // own user even on servers without extended-profile (m.tz) support. setAccountData(mx, 'im.lotus.timezone', { timezone: value }), // Mirror the pronouns write path so OTHER users can read the timezone // via the m.tz profile field. Best-effort: standard Synapse rejects // unknown profile fields, so a failure here must not fail the save. mx.http .authedRequest(Method.Put, `/profile/${encodeURIComponent(userId)}/m.tz`, undefined, { 'm.tz': value, }) .catch(() => undefined), ]).then(() => { setSavedTimezone(value); }), [mx, userId], ), ); const saving = saveState.status === AsyncStatus.Loading; const handleReset = () => { setTimezone(savedTimezone); }; const handleSubmit: FormEventHandler = (evt) => { evt.preventDefault(); if (saving) return; saveTimezone(timezone); }; const hasChanges = timezone !== savedTimezone; return ( Timezone } description={ Your local timezone. Visible to other users. } > ({ value: tz, label: tz })), ]} onChange={setTimezone} disabled={saving} aria-label="Timezone" /> {hasChanges && !saving && ( )} {saveState.status === AsyncStatus.Error && ( Failed to save timezone. Try again. )} ); } export function Profile() { const mx = useMatrixClient(); const userId = mx.getUserId()!; const profile = useUserProfile(userId); return ( Profile ); }