diff --git a/.changeset/aaa_syncupstream.md b/.changeset/aaa_syncupstream.md new file mode 100644 index 000000000..af863df74 --- /dev/null +++ b/.changeset/aaa_syncupstream.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Merge in upstream call things and remove the duplicate new voice room button. diff --git a/src/app/cs-api.ts b/src/app/cs-api.ts index b9aac06ab..42d689b98 100644 --- a/src/app/cs-api.ts +++ b/src/app/cs-api.ts @@ -20,6 +20,16 @@ export type AutoDiscoveryInfo = Record & { 'm.identity_server'?: { base_url: string; }; + 'org.matrix.msc2965.authentication'?: { + account?: string; + issuer?: string; + }; + 'org.matrix.msc4143.rtc_foci'?: [ + { + livekit_service_url: string; + type: 'livekit'; + }, + ]; }; export const autoDiscovery = async ( diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index bb682a36e..128df221f 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -1,13 +1,17 @@ -import { Box, Chip, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider } from 'folds'; -import { useState } from 'react'; +import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds'; +import { useCallback } from 'react'; +import { useSetAtom } from 'jotai'; import { StatusDivider } from './components'; import { CallEmbed, useCallControlState } from '../../plugins/call'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { callEmbedAtom } from '../../state/callEmbed'; type MicrophoneButtonProps = { enabled: boolean; onToggle: () => Promise; + disabled?: boolean; }; -function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { +function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) { return ( onToggle()} outlined + disabled={disabled} > @@ -37,8 +42,9 @@ function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { type SoundButtonProps = { enabled: boolean; onToggle: () => void; + disabled?: boolean; }; -function SoundButton({ enabled, onToggle }: SoundButtonProps) { +function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) { return ( onToggle()} outlined + disabled={disabled} > Promise; + disabled?: boolean; }; -function VideoButton({ enabled, onToggle }: VideoButtonProps) { +function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) { return ( onToggle()} outlined + disabled={disabled} > void; + disabled?: boolean; +}; +function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) { return ( setEnabled(!enabled)} + onClick={onToggle} outlined + disabled={disabled} > @@ -133,8 +146,31 @@ function ScreenShareButton() { ); } -export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) { - const { microphone, video, sound } = useCallControlState(callEmbed.control); +export function CallControl({ + callEmbed, + compact, + callJoined, +}: { + callEmbed: CallEmbed; + compact: boolean; + callJoined: boolean; +}) { + const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control); + const setCallEmbed = useSetAtom(callEmbedAtom); + + const [hangupState, hangup] = useAsyncCallback( + useCallback(() => callEmbed.hangup(), [callEmbed]) + ); + const exiting = + hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; + + const handleHangup = () => { + if (!callJoined) { + setCallEmbed(undefined); + return; + } + hangup(); + }; return ( @@ -142,23 +178,48 @@ export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) { callEmbed.control.toggleMicrophone()} + disabled={!callJoined} + /> + callEmbed.control.toggleSound()} + disabled={!callJoined} /> - callEmbed.control.toggleSound()} /> - callEmbed.control.toggleVideo()} /> - {false && } + {!compact && } + callEmbed.control.toggleVideo()} + disabled={!callJoined} + /> + {!compact && ( + callEmbed.control.toggleScreenshare()} + disabled={!callJoined} + /> + )} } + before={ + exiting ? ( + + ) : ( + + ) + } + disabled={exiting} outlined - onClick={() => callEmbed.hangup()} + onClick={handleHangup} > - - End - + {!compact && ( + + End + + )} ); diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx index 7a9177d48..899bff7d6 100644 --- a/src/app/features/call-status/CallStatus.tsx +++ b/src/app/features/call-status/CallStatus.tsx @@ -46,18 +46,17 @@ export function CallStatus({ callEmbed }: CallStatusProps) { ) : ( )} - - - {speakers.size > 0 && !compact && ( + + {!compact && ( <> - - - + + {speakers.size > 0 && ( + <> + + + + + )} )} @@ -68,8 +67,13 @@ export function CallStatus({ callEmbed }: CallStatusProps) { )} {memberVisible && !compact && } - - + + {compact && ( + + + + )} + ); diff --git a/src/app/features/call-status/LiveChip.tsx b/src/app/features/call-status/LiveChip.tsx index 3806fd0e0..e62af0e24 100644 --- a/src/app/features/call-status/LiveChip.tsx +++ b/src/app/features/call-status/LiveChip.tsx @@ -128,7 +128,7 @@ export function LiveChip({ count, room, members }: LiveChipProps) { radii="Pill" onClick={handleOpenMenu} > - + {count} Live diff --git a/src/app/features/call-status/MemberSpeaking.tsx b/src/app/features/call-status/MemberSpeaking.tsx index 431bdb128..b80190cd3 100644 --- a/src/app/features/call-status/MemberSpeaking.tsx +++ b/src/app/features/call-status/MemberSpeaking.tsx @@ -13,7 +13,7 @@ export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) { ); return ( - + {speakingNames.length === 1 && ( <> diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx new file mode 100644 index 000000000..cfd85d650 --- /dev/null +++ b/src/app/features/call/CallControls.tsx @@ -0,0 +1,203 @@ +import { MouseEventHandler, useCallback, useRef, useState } from 'react'; +import { + Box, + Button, + config, + Icon, + IconButton, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Spinner, + Text, + toRem, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { SequenceCard } from '../../components/sequence-card'; +import * as css from './styles.css'; +import { + ChatButton, + ControlDivider, + MicrophoneButton, + ScreenShareButton, + SoundButton, + VideoButton, +} from './Controls'; +import { CallEmbed, useCallControlState } from '../../plugins/call'; +import { useResizeObserver } from '../../hooks/useResizeObserver'; +import { stopPropagation } from '../../utils/keyboard'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; + +type CallControlsProps = { + callEmbed: CallEmbed; +}; +export function CallControls({ callEmbed }: CallControlsProps) { + const controlRef = useRef(null); + const [compact, setCompact] = useState(document.body.clientWidth < 500); + + useResizeObserver( + useCallback(() => { + const element = controlRef.current; + if (!element) return; + setCompact(element.clientWidth < 500); + }, []), + useCallback(() => controlRef.current, []) + ); + + const { microphone, video, sound, screenshare, spotlight } = useCallControlState( + callEmbed.control + ); + + const [cords, setCords] = useState(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const handleSpotlightClick = () => { + callEmbed.control.toggleSpotlight(); + setCords(undefined); + }; + + const handleReactionsClick = () => { + callEmbed.control.toggleReactions(); + setCords(undefined); + }; + + const handleSettingsClick = () => { + callEmbed.control.toggleSettings(); + setCords(undefined); + }; + + const [hangupState, hangup] = useAsyncCallback( + useCallback(() => callEmbed.hangup(), [callEmbed]) + ); + const exiting = + hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; + + return ( + + + + + callEmbed.control.toggleMicrophone()} + /> + callEmbed.control.toggleSound()} /> + + {!compact && } + + callEmbed.control.toggleVideo()} /> + callEmbed.control.toggleScreenshare()} + /> + + + {!compact && } + + + + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + + + {spotlight ? 'Grid View' : 'Spotlight View'} + + + + + Reactions + + + + + Settings + + + + + + } + > + + + + + + + + + + + + ); +} diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index ff59e7111..bfb3b16ec 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -1,21 +1,41 @@ -import { useRef } from 'react'; +import { type RefObject, useRef } from 'react'; import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; -import { EventType } from '$types/matrix-sdk'; -import { useCallEmbed, useCallEmbedPlacementSync, useCallJoined } from '$hooks/useCallEmbed'; import { ContainerColor } from '$styles/ContainerColor.css'; import { usePowerLevelsContext } from '$hooks/usePowerLevels'; -import { useRoom } from '$hooks/useRoom'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useRoom } from '$hooks/useRoom'; +import { useLivekitSupport } from '$hooks/useLivekitSupport'; +import { StateEvent } from '$types/matrix/room'; import { useCallMembers, useCallSession } from '$hooks/useCall'; -import { PrescreenControls } from './PrescreenControls'; -import { CallMemberRenderer } from './CallMemberCard'; +import { useCallEmbed, useCallEmbedPlacementSync, useCallJoined } from '$hooks/useCallEmbed'; import * as css from './styles.css'; +import { CallMemberRenderer } from './CallMemberCard'; +import { PrescreenControls } from './PrescreenControls'; +import { CallControls } from './CallControls'; -function JoinMessage({ hasParticipant }: { hasParticipant?: boolean }) { +function LivekitServerMissingMessage() { + return ( + + Your homeserver does not support calling. But you can still join call started by others. + + ); +} + +function JoinMessage({ + hasParticipant, + livekitSupported, +}: { + hasParticipant?: boolean; + livekitSupported?: boolean; +}) { if (hasParticipant) return null; + if (livekitSupported === false) { + return ; + } + return ( Voice chat's empty — Be the first to hop in! @@ -39,67 +59,92 @@ function AlreadyInCallMessage() { ); } -export function CallView() { +function CallPrescreen() { const mx = useMatrixClient(); const room = useRoom(); - - const callViewRef = useRef(null); - useCallEmbedPlacementSync(callViewRef); + const livekitSupported = useLivekitSupport(); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); const permissions = useRoomPermissions(creators, powerLevels); - const canJoin = permissions.event(EventType.GroupCallMemberPrefix, mx.getSafeUserId()); + const hasPermission = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId()); const callSession = useCallSession(room); const callMembers = useCallMembers(room, callSession); const hasParticipant = callMembers.length > 0; const callEmbed = useCallEmbed(); - const callJoined = useCallJoined(callEmbed); const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId; + const canJoin = hasPermission && (livekitSupported || hasParticipant); + + return ( + + + + {hasParticipant && ( +
+ + Participant + + + + {callMembers.length} Live + + +
+ )} + + + + {!inOtherCall && + (hasPermission ? ( + + ) : ( + + ))} + {inOtherCall && } + +
+
+
+ ); +} + +type CallJoinedProps = { + containerRef: RefObject; + joined: boolean; +}; +function CallJoined({ joined, containerRef }: CallJoinedProps) { + const callEmbed = useCallEmbed(); + + return ( + + + {callEmbed && joined && } + + ); +} + +export function CallView() { + const room = useRoom(); + const callContainerRef = useRef(null); + useCallEmbedPlacementSync(callContainerRef); + + const callEmbed = useCallEmbed(); + const callJoined = useCallJoined(callEmbed); + const currentJoined = callEmbed?.roomId === room.roomId && callJoined; return ( - {!currentJoined && ( - - - - {hasParticipant && ( -
- - Participant - - - - {callMembers.length} Live - - -
- )} - - -
- {!inOtherCall && - (canJoin ? ( - - ) : ( - - ))} - {inOtherCall && } -
-
-
-
- )} + {!currentJoined && } +
); } diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx index 0e0a07cda..3fe6d68fe 100644 --- a/src/app/features/call/Controls.tsx +++ b/src/app/features/call/Controls.tsx @@ -113,6 +113,38 @@ export function VideoButton({ enabled, onToggle }: VideoButtonProps) { ); } +type ScreenShareButtonProps = { + enabled: boolean; + onToggle: () => void; +}; +export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) { + return ( + + {enabled ? 'Stop Screenshare' : 'Start Screenshare'} + + } + > + {(anchorRef) => ( + onToggle()} + outlined + > + + + )} + + ); +} + export function ChatButton() { const [chat, setChat] = useAtom(callChatAtom); diff --git a/src/app/features/call/PrescreenControls.tsx b/src/app/features/call/PrescreenControls.tsx index 705a4d1e6..d5dc1464a 100644 --- a/src/app/features/call/PrescreenControls.tsx +++ b/src/app/features/call/PrescreenControls.tsx @@ -5,7 +5,6 @@ import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton import { useIsDirectRoom, useRoom } from '../../hooks/useRoom'; import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed'; import { useCallPreferences } from '../../state/hooks/callPreferences'; -import { CallControlState } from '../../plugins/call/CallControlState'; type PrescreenControlsProps = { canJoin?: boolean; @@ -49,7 +48,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) { -
- -
- - )} - {loading || !mx ? ( - - ) : ( - - - {(serverConfigs) => ( - - - - {children} - - - - )} - - - )} - + + + {mx && } + {loading && } + {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && ( + + + + + {loadState.status === AsyncStatus.Error && ( + {`Failed to load. ${loadState.error.message}`} + )} + {startState.status === AsyncStatus.Error && ( + {`Failed to start. ${startState.error.message}`} + )} + + + + + + )} + {loading || !mx ? ( + + ) : ( + + + {(serverConfigs) => ( + + + + {children} + + + + )} + + + )} + + ); } diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index 5a8578f66..c30ffc1c7 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -14,12 +14,58 @@ export class CallControl extends EventEmitter implements CallControlState { private iframe: HTMLIFrameElement; + private controlMutationObserver: MutationObserver; + + private get document(): Document | undefined { + return this.iframe.contentDocument ?? this.iframe.contentWindow?.document; + } + + private get screenshareButton(): HTMLElement | undefined { + const screenshareBtn = this.document?.querySelector( + '[data-testid="incall_screenshare"]' + ) as HTMLElement | null; + + return screenshareBtn ?? undefined; + } + + private get settingsButton(): HTMLElement | undefined { + const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]'); + + const settingsButton = leaveBtn?.previousElementSibling as HTMLElement | null; + + return settingsButton ?? undefined; + } + + private get reactionsButton(): HTMLElement | undefined { + const reactionsButton = this.settingsButton?.previousElementSibling as HTMLElement | null; + + return reactionsButton ?? undefined; + } + + private get spotlightButton(): HTMLInputElement | undefined { + const spotlightButton = this.document?.querySelector( + 'input[value="spotlight"]' + ) as HTMLInputElement | null; + + return spotlightButton ?? undefined; + } + + private get gridButton(): HTMLInputElement | undefined { + const gridButton = this.document?.querySelector( + 'input[value="grid"]' + ) as HTMLInputElement | null; + + return gridButton ?? undefined; + } + constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) { super(); this.state = state; this.call = call; this.iframe = iframe; + + this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this)); } public getState(): CallControlState { @@ -38,6 +84,14 @@ export class CallControl extends EventEmitter implements CallControlState { return this.state.sound; } + public get screenshare(): boolean { + return this.state.screenshare; + } + + public get spotlight(): boolean { + return this.state.spotlight; + } + public async applyState() { await this.setMediaState({ audio_enabled: this.microphone, @@ -47,6 +101,26 @@ export class CallControl extends EventEmitter implements CallControlState { this.emitStateUpdate(); } + public startObserving() { + this.controlMutationObserver.disconnect(); + + const screenshareBtn = this.screenshareButton; + if (screenshareBtn) { + this.controlMutationObserver.observe(screenshareBtn, { + attributes: true, + attributeFilter: ['data-kind'], + }); + } + const spotlightBtn = this.spotlightButton; + if (spotlightBtn) { + this.controlMutationObserver.observe(spotlightBtn, { + attributes: true, + }); + } + + this.onControlMutation(); + } + public applySound() { this.setSound(this.sound); } @@ -72,7 +146,9 @@ export class CallControl extends EventEmitter implements CallControlState { const state = new CallControlState( data.audio_enabled ?? this.microphone, data.video_enabled ?? this.video, - this.sound + this.sound, + this.screenshare, + this.spotlight ); this.state = state; @@ -83,6 +159,20 @@ export class CallControl extends EventEmitter implements CallControlState { } } + public onControlMutation() { + const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary'; + const spotlight: boolean = this.spotlightButton?.checked ?? false; + + this.state = new CallControlState( + this.microphone, + this.video, + this.sound, + screenshare, + spotlight + ); + this.emitStateUpdate(); + } + public toggleMicrophone() { const payload: ElementMediaStatePayload = { audio_enabled: !this.microphone, @@ -104,7 +194,13 @@ export class CallControl extends EventEmitter implements CallControlState { this.setSound(sound); - const state = new CallControlState(this.microphone, this.video, sound); + const state = new CallControlState( + this.microphone, + this.video, + sound, + this.screenshare, + this.spotlight + ); this.state = state; this.emitStateUpdate(); @@ -113,6 +209,30 @@ export class CallControl extends EventEmitter implements CallControlState { } } + public toggleScreenshare() { + this.screenshareButton?.click(); + } + + public toggleSpotlight() { + if (this.spotlight) { + this.gridButton?.click(); + return; + } + this.spotlightButton?.click(); + } + + public toggleReactions() { + this.reactionsButton?.click(); + } + + public toggleSettings() { + this.settingsButton?.click(); + } + + public dispose() { + this.controlMutationObserver.disconnect(); + } + private emitStateUpdate() { this.emit(CallControlEvent.StateUpdate); } diff --git a/src/app/plugins/call/CallControlState.ts b/src/app/plugins/call/CallControlState.ts index 42f0b1965..fc7e3f22b 100644 --- a/src/app/plugins/call/CallControlState.ts +++ b/src/app/plugins/call/CallControlState.ts @@ -5,9 +5,21 @@ export class CallControlState { public readonly sound: boolean; - constructor(microphone: boolean, video: boolean, sound: boolean) { + public readonly screenshare: boolean; + + public readonly spotlight: boolean; + + constructor( + microphone: boolean, + video: boolean, + sound: boolean, + screenshare = false, + spotlight = false + ) { this.microphone = microphone; this.video = video; this.sound = sound; + this.screenshare = screenshare; + this.spotlight = spotlight; } } diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index c0c02f9c1..c370bfac5 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -146,7 +146,7 @@ export class CallEmbed { let initialMediaEvent = true; this.disposables.push( - this.listenEvent(ElementWidgetActions.DeviceMute, (evt) => { + this.listenAction(ElementWidgetActions.DeviceMute, (evt) => { if (initialMediaEvent) { initialMediaEvent = false; this.control.applyState(); @@ -177,18 +177,27 @@ export class CallEmbed { return this.call.transport.send(ElementWidgetActions.HangupCall, {}); } - public listenEvent(type: string, callback: (event: CustomEvent) => void) { - this.call.on(`action:${type}`, callback); - return () => { - this.call.off(`action:${type}`, callback); - }; + public onPreparing(callback: () => void) { + return this.listenEvent('preparing', callback); + } + + public onPreparingError(callback: (error: any) => void) { + return this.listenEvent('error:preparing', callback); + } + + public onReady(callback: () => void) { + return this.listenEvent('ready', callback); + } + + public onCapabilitiesNotified(callback: () => void) { + return this.listenEvent('capabilitiesNotified', callback); } private start() { // Room widgets get locked to the room they were added in this.call.setViewedRoomId(this.roomId); this.disposables.push( - this.listenEvent(ElementWidgetActions.JoinCall, this.onCallJoined.bind(this)) + this.listenAction(ElementWidgetActions.JoinCall, this.onCallJoined.bind(this)) ); // Populate the map of "read up to" events for this widget with the current event in every room. @@ -220,6 +229,7 @@ export class CallEmbed { }); this.call.stop(); this.container.removeChild(this.iframe); + this.control.dispose(); this.mx.off(ClientEvent.Event, this.onEvent.bind(this)); this.mx.off(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); @@ -233,6 +243,21 @@ export class CallEmbed { private onCallJoined(): void { this.joined = true; + this.applyStyles(); + this.control.startObserving(); + } + + private applyStyles(): void { + const doc = this.document; + if (!doc) return; + + doc.body.style.setProperty('background', 'none', 'important'); + const controls = doc.body.querySelector('[data-testid="incall_leave"]')?.parentElement + ?.parentElement; + if (controls) { + controls.style.setProperty('position', 'absolute'); + controls.style.setProperty('visibility', 'hidden'); + } } private onEvent(ev: MatrixEvent): void { @@ -359,4 +384,15 @@ export class CallEmbed { } } } + + public listenAction(type: string, callback: (event: CustomEvent) => void) { + return this.listenEvent(`action:${type}`, callback); + } + + public listenEvent(type: string, callback: (event: T) => void) { + this.call.on(type, callback); + return () => { + this.call.off(type, callback); + }; + } } diff --git a/src/app/plugins/recent-emoji.ts b/src/app/plugins/recent-emoji.ts index c511159d1..ca7b25cc1 100644 --- a/src/app/plugins/recent-emoji.ts +++ b/src/app/plugins/recent-emoji.ts @@ -27,7 +27,11 @@ export const getRecentEmojis = (mx: MatrixClient, limit?: number): IEmoji[] => { export function addRecentEmoji(mx: MatrixClient, unicode: string) { const recentEmojiEvent = getAccountData(mx, AccountDataEvent.ElementRecentEmoji); - const recentEmoji = recentEmojiEvent?.getContent().recent_emoji ?? []; + const recentEmojiContent = recentEmojiEvent?.getContent(); + const recentEmoji = + recentEmojiContent && Array.isArray(recentEmojiContent.recent_emoji) + ? structuredClone(recentEmojiContent.recent_emoji) + : []; const emojiIndex = recentEmoji.findIndex(([u]) => u === unicode); let entry: [EmojiUnicode, EmojiUsageCount]; diff --git a/src/app/utils/common.ts b/src/app/utils/common.ts index 8507e06ce..fb1e0b16b 100644 --- a/src/app/utils/common.ts +++ b/src/app/utils/common.ts @@ -87,16 +87,16 @@ export const scaleYDimension = (x: number, scaledX: number, y: number): number = }; export const parseGeoUri = (location: string) => { - if (!location) return null; + if (!location) return undefined; const [, data] = location.split(':'); - if (!data) return null; + if (!data) return undefined; const [cords] = data.split(';'); - if (!cords) return null; + if (!cords) return undefined; const [latitude, longitude] = cords.split(','); - if (!latitude || !longitude) return null; + if (!latitude || !longitude) return undefined; return { latitude, @@ -112,8 +112,8 @@ export const trimTrailingSlash = (str: string): string => str.replace(END_SLASHE export const trimSlash = (str: string): string => trimLeadingSlash(trimTrailingSlash(str)); export const nameInitials = (str: string | undefined | null, len = 1): string => { - if (!str) return '�'; - return [...str].slice(0, len).join('') || '�'; + if (!str) return ''; + return [...str].slice(0, len).join('') || ''; }; export const randomStr = (len = 12): string => {