2024-04-20 04:34:43 +08:00
|
|
|
import React, { useState, useEffect } from 'react';
|
|
|
|
import Resizable from 're-resizable';
|
2024-05-02 03:48:12 +08:00
|
|
|
import Draggable, { DraggableEvent } from 'react-draggable';
|
2024-04-20 04:34:43 +08:00
|
|
|
import { Session } from 'meteor/session';
|
|
|
|
import { withTracker } from 'meteor/react-meteor-data';
|
|
|
|
import { useSubscription } from '@apollo/client';
|
|
|
|
import { useVideoStreams } from '/imports/ui/components/video-provider/video-provider-graphql/hooks';
|
|
|
|
import {
|
|
|
|
layoutSelect,
|
|
|
|
layoutSelectInput,
|
|
|
|
layoutSelectOutput,
|
|
|
|
layoutDispatch,
|
|
|
|
} from '/imports/ui/components/layout/context';
|
|
|
|
import Settings from '/imports/ui/services/settings';
|
|
|
|
import { LAYOUT_TYPE, ACTIONS, CAMERADOCK_POSITION } from '/imports/ui/components/layout/enums';
|
|
|
|
import { CURRENT_PRESENTATION_PAGE_SUBSCRIPTION } from '/imports/ui/components/whiteboard/queries';
|
|
|
|
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
|
|
|
|
import DropAreaContainer from './drop-areas/container';
|
|
|
|
import VideoProviderContainer from '/imports/ui/components/video-provider/video-provider-graphql/container';
|
|
|
|
import Storage from '/imports/ui/services/storage/session';
|
2024-05-02 03:48:12 +08:00
|
|
|
import Styled from './styles';
|
|
|
|
import { Input, Layout, Output } from '/imports/ui/components/layout/layoutTypes';
|
|
|
|
import { VideoItem } from '/imports/ui/components/video-provider/video-provider-graphql/types';
|
|
|
|
|
|
|
|
interface WebcamComponentGraphqlProps {
|
|
|
|
cameraDock: Output['cameraDock'];
|
|
|
|
swapLayout: boolean;
|
|
|
|
focusedId: string;
|
|
|
|
layoutContextDispatch: (...args: unknown[]) => void;
|
|
|
|
fullscreen: Layout['fullscreen'];
|
|
|
|
isPresenter: boolean;
|
|
|
|
displayPresentation: boolean;
|
|
|
|
cameraOptimalGridSize: Input['cameraDock']['cameraOptimalGridSize'];
|
|
|
|
isRTL: boolean;
|
|
|
|
isGridEnabled: boolean;
|
|
|
|
}
|
2024-04-20 04:34:43 +08:00
|
|
|
|
2024-05-02 03:48:12 +08:00
|
|
|
const WebcamComponentGraphql: React.FC<WebcamComponentGraphqlProps> = ({
|
2024-04-20 04:34:43 +08:00
|
|
|
cameraDock,
|
|
|
|
swapLayout,
|
|
|
|
focusedId,
|
|
|
|
layoutContextDispatch,
|
|
|
|
fullscreen,
|
|
|
|
isPresenter,
|
|
|
|
displayPresentation,
|
|
|
|
cameraOptimalGridSize: cameraSize,
|
|
|
|
isRTL,
|
|
|
|
isGridEnabled,
|
|
|
|
}) => {
|
|
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
const [isFullscreen, setIsFullScreen] = useState(false);
|
|
|
|
const [resizeStart, setResizeStart] = useState({ width: 0, height: 0 });
|
|
|
|
const [cameraMaxWidth, setCameraMaxWidth] = useState(0);
|
|
|
|
const [draggedAtLeastOneTime, setDraggedAtLeastOneTime] = useState(false);
|
|
|
|
|
|
|
|
const lastSize = Storage.getItem('webcamSize') || { width: 0, height: 0 };
|
2024-05-02 03:48:12 +08:00
|
|
|
const { height: lastHeight } = lastSize as { width: number, height: number };
|
2024-04-20 04:34:43 +08:00
|
|
|
|
|
|
|
const isCameraTopOrBottom = cameraDock.position === CAMERADOCK_POSITION.CONTENT_TOP
|
|
|
|
|| cameraDock.position === CAMERADOCK_POSITION.CONTENT_BOTTOM;
|
|
|
|
const isCameraLeftOrRight = cameraDock.position === CAMERADOCK_POSITION.CONTENT_LEFT
|
|
|
|
|| cameraDock.position === CAMERADOCK_POSITION.CONTENT_RIGHT;
|
|
|
|
const isCameraSidebar = cameraDock.position === CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM;
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
const handleVisibility = () => {
|
|
|
|
if (document.hidden) {
|
|
|
|
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
document.addEventListener('visibilitychange', handleVisibility);
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
document.removeEventListener('visibilitychange', handleVisibility);
|
|
|
|
};
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
setIsFullScreen(fullscreen.group === 'webcams');
|
|
|
|
}, [fullscreen]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
2024-05-02 03:48:12 +08:00
|
|
|
const newCameraMaxWidth = (isPresenter && cameraDock.presenterMaxWidth)
|
|
|
|
? cameraDock.presenterMaxWidth
|
|
|
|
: cameraDock.maxWidth;
|
2024-04-20 04:34:43 +08:00
|
|
|
setCameraMaxWidth(newCameraMaxWidth);
|
|
|
|
|
|
|
|
if (isCameraLeftOrRight && cameraDock.width > newCameraMaxWidth) {
|
|
|
|
layoutContextDispatch(
|
|
|
|
{
|
|
|
|
type: ACTIONS.SET_CAMERA_DOCK_SIZE,
|
|
|
|
value: {
|
|
|
|
width: newCameraMaxWidth,
|
|
|
|
height: cameraDock.height,
|
|
|
|
browserWidth: window.innerWidth,
|
|
|
|
browserHeight: window.innerHeight,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
);
|
|
|
|
Storage.setItem('webcamSize', { width: newCameraMaxWidth, height: lastHeight });
|
|
|
|
}
|
|
|
|
|
|
|
|
const cams = document.getElementById('cameraDock');
|
2024-05-02 03:48:12 +08:00
|
|
|
cams?.setAttribute('data-position', cameraDock.position);
|
2024-04-20 04:34:43 +08:00
|
|
|
}, [cameraDock.position, cameraDock.maxWidth, isPresenter, displayPresentation]);
|
|
|
|
|
2024-05-02 03:48:12 +08:00
|
|
|
const handleVideoFocus = (id: string) => {
|
2024-04-20 04:34:43 +08:00
|
|
|
layoutContextDispatch({
|
|
|
|
type: ACTIONS.SET_FOCUSED_CAMERA_ID,
|
|
|
|
value: focusedId !== id ? id : false,
|
|
|
|
});
|
2024-05-02 03:48:12 +08:00
|
|
|
};
|
2024-04-20 04:34:43 +08:00
|
|
|
|
2024-05-02 03:48:12 +08:00
|
|
|
const onResizeHandle = (deltaWidth: number, deltaHeight: number) => {
|
|
|
|
if (cameraDock.resizableEdge?.top || cameraDock.resizableEdge?.bottom) {
|
2024-04-20 04:34:43 +08:00
|
|
|
layoutContextDispatch(
|
|
|
|
{
|
|
|
|
type: ACTIONS.SET_CAMERA_DOCK_SIZE,
|
|
|
|
value: {
|
|
|
|
width: cameraDock.width,
|
|
|
|
height: resizeStart.height + deltaHeight,
|
|
|
|
browserWidth: window.innerWidth,
|
|
|
|
browserHeight: window.innerHeight,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
);
|
|
|
|
}
|
2024-05-02 03:48:12 +08:00
|
|
|
if (cameraDock.resizableEdge?.left || cameraDock.resizableEdge?.right) {
|
2024-04-20 04:34:43 +08:00
|
|
|
layoutContextDispatch(
|
|
|
|
{
|
|
|
|
type: ACTIONS.SET_CAMERA_DOCK_SIZE,
|
|
|
|
value: {
|
|
|
|
width: resizeStart.width + deltaWidth,
|
|
|
|
height: cameraDock.height,
|
|
|
|
browserWidth: window.innerWidth,
|
|
|
|
browserHeight: window.innerHeight,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
const handleWebcamDragStart = () => {
|
|
|
|
setIsDragging(true);
|
|
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
layoutContextDispatch({
|
|
|
|
type: ACTIONS.SET_CAMERA_DOCK_IS_DRAGGING,
|
|
|
|
value: true,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2024-05-02 03:48:12 +08:00
|
|
|
const handleWebcamDragStop = (e: DraggableEvent) => {
|
2024-04-20 04:34:43 +08:00
|
|
|
setIsDragging(false);
|
|
|
|
setDraggedAtLeastOneTime(false);
|
|
|
|
document.body.style.overflow = 'auto';
|
2024-05-02 03:48:12 +08:00
|
|
|
const dropAreaId = (e.target as HTMLDivElement).id;
|
2024-04-20 04:34:43 +08:00
|
|
|
|
2024-05-02 03:48:12 +08:00
|
|
|
if (Object.values(CAMERADOCK_POSITION).includes(dropAreaId) && draggedAtLeastOneTime) {
|
2024-04-20 04:34:43 +08:00
|
|
|
const layout = document.getElementById('layout');
|
2024-05-02 03:48:12 +08:00
|
|
|
layout?.setAttribute('data-cam-position', dropAreaId);
|
2024-04-20 04:34:43 +08:00
|
|
|
|
|
|
|
layoutContextDispatch({
|
|
|
|
type: ACTIONS.SET_CAMERA_DOCK_POSITION,
|
2024-05-02 03:48:12 +08:00
|
|
|
value: dropAreaId,
|
2024-04-20 04:34:43 +08:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
layoutContextDispatch({
|
|
|
|
type: ACTIONS.SET_CAMERA_DOCK_IS_DRAGGING,
|
|
|
|
value: false,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2024-05-02 03:48:12 +08:00
|
|
|
const draggableOffset = {
|
2024-04-20 04:34:43 +08:00
|
|
|
left: isDragging && (isCameraTopOrBottom || isCameraSidebar)
|
2024-05-02 03:48:12 +08:00
|
|
|
? ((cameraDock.width - (cameraSize?.width ?? 0)) / 2)
|
2024-04-20 04:34:43 +08:00
|
|
|
: 0,
|
|
|
|
top: isDragging && isCameraLeftOrRight
|
2024-05-02 03:48:12 +08:00
|
|
|
? ((cameraDock.height - (cameraSize?.height ?? 0)) / 2)
|
2024-04-20 04:34:43 +08:00
|
|
|
: 0,
|
|
|
|
};
|
|
|
|
|
|
|
|
if (isRTL) {
|
2024-05-02 03:48:12 +08:00
|
|
|
draggableOffset.left *= -1;
|
2024-04-20 04:34:43 +08:00
|
|
|
}
|
|
|
|
|
2024-05-02 03:48:12 +08:00
|
|
|
const isIphone = !!(navigator.userAgent.match(/iPhone/i));
|
|
|
|
const mobileWidth = `${isDragging ? cameraSize?.width : cameraDock.width}pt`;
|
|
|
|
const mobileHeight = `${isDragging ? cameraSize?.height : cameraDock.height}pt`;
|
|
|
|
const isDesktopWidth = isDragging ? cameraSize?.width : cameraDock.width;
|
|
|
|
const isDesktopHeight = isDragging ? cameraSize?.height : cameraDock.height;
|
2024-04-20 04:34:43 +08:00
|
|
|
const camOpacity = isDragging ? 0.5 : undefined;
|
2024-05-02 03:48:12 +08:00
|
|
|
|
2024-04-20 04:34:43 +08:00
|
|
|
return (
|
|
|
|
<>
|
|
|
|
{isDragging ? <DropAreaContainer /> : null}
|
|
|
|
<Styled.ResizableWrapper
|
2024-05-02 03:48:12 +08:00
|
|
|
$horizontal={cameraDock.position === CAMERADOCK_POSITION.CONTENT_TOP
|
2024-04-20 04:34:43 +08:00
|
|
|
|| cameraDock.position === CAMERADOCK_POSITION.CONTENT_BOTTOM}
|
2024-05-02 03:48:12 +08:00
|
|
|
$vertical={cameraDock.position === CAMERADOCK_POSITION.CONTENT_LEFT
|
2024-04-20 04:34:43 +08:00
|
|
|
|| cameraDock.position === CAMERADOCK_POSITION.CONTENT_RIGHT}
|
|
|
|
>
|
|
|
|
<Draggable
|
|
|
|
handle="video"
|
|
|
|
bounds="html"
|
|
|
|
onStart={handleWebcamDragStart}
|
|
|
|
onDrag={() => {
|
|
|
|
if (!draggedAtLeastOneTime) {
|
|
|
|
setDraggedAtLeastOneTime(true);
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
onStop={handleWebcamDragStop}
|
|
|
|
onMouseDown={
|
|
|
|
cameraDock.isDraggable ? (e) => e.preventDefault() : undefined
|
|
|
|
}
|
|
|
|
disabled={!cameraDock.isDraggable || isResizing || isFullscreen}
|
|
|
|
position={
|
|
|
|
{
|
|
|
|
x: cameraDock.left - cameraDock.right + draggableOffset.left,
|
|
|
|
y: cameraDock.top + draggableOffset.top,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
>
|
|
|
|
<Resizable
|
2024-05-02 03:48:12 +08:00
|
|
|
minWidth={isDragging ? cameraSize?.width : cameraDock.minWidth}
|
|
|
|
minHeight={isDragging ? cameraSize?.height : cameraDock.minHeight}
|
|
|
|
maxWidth={isDragging ? cameraSize?.width : cameraMaxWidth}
|
|
|
|
maxHeight={isDragging ? cameraSize?.height : cameraDock.maxHeight}
|
2024-04-20 04:34:43 +08:00
|
|
|
size={{
|
2024-05-02 03:48:12 +08:00
|
|
|
width: isDragging ? cameraSize?.width : cameraDock.width,
|
|
|
|
height: isDragging ? cameraSize?.height : cameraDock.height,
|
2024-04-20 04:34:43 +08:00
|
|
|
}}
|
|
|
|
onResizeStart={() => {
|
|
|
|
setIsResizing(true);
|
|
|
|
setResizeStart({ width: cameraDock.width, height: cameraDock.height });
|
|
|
|
onResizeHandle(cameraDock.width, cameraDock.height);
|
|
|
|
layoutContextDispatch({
|
|
|
|
type: ACTIONS.SET_CAMERA_DOCK_IS_RESIZING,
|
|
|
|
value: true,
|
|
|
|
});
|
|
|
|
}}
|
2024-05-02 03:48:12 +08:00
|
|
|
onResize={(_, __, ___, d) => {
|
2024-04-20 04:34:43 +08:00
|
|
|
onResizeHandle(d.width, d.height);
|
|
|
|
}}
|
|
|
|
onResizeStop={() => {
|
|
|
|
setResizeStart({ width: 0, height: 0 });
|
|
|
|
setTimeout(() => setIsResizing(false), 500);
|
|
|
|
layoutContextDispatch({
|
|
|
|
type: ACTIONS.SET_CAMERA_DOCK_IS_RESIZING,
|
|
|
|
value: false,
|
|
|
|
});
|
|
|
|
}}
|
|
|
|
enable={{
|
2024-05-02 03:48:12 +08:00
|
|
|
top: !isFullscreen && !isDragging && !swapLayout && cameraDock?.resizableEdge?.top,
|
2024-04-20 04:34:43 +08:00
|
|
|
bottom: !isFullscreen && !isDragging && !swapLayout
|
2024-05-02 03:48:12 +08:00
|
|
|
&& cameraDock?.resizableEdge?.bottom,
|
|
|
|
left: !isFullscreen && !isDragging && !swapLayout && cameraDock?.resizableEdge?.left,
|
|
|
|
right: !isFullscreen && !isDragging && !swapLayout && cameraDock?.resizableEdge?.right,
|
2024-04-20 04:34:43 +08:00
|
|
|
topLeft: false,
|
|
|
|
topRight: false,
|
|
|
|
bottomLeft: false,
|
|
|
|
bottomRight: false,
|
|
|
|
}}
|
|
|
|
style={{
|
|
|
|
position: 'absolute',
|
2024-05-02 03:48:12 +08:00
|
|
|
zIndex: isCameraSidebar && !isDragging ? 0 : cameraDock?.zIndex,
|
2024-04-20 04:34:43 +08:00
|
|
|
}}
|
|
|
|
>
|
|
|
|
<Styled.Draggable
|
2024-05-02 03:48:12 +08:00
|
|
|
$isDraggable={!!cameraDock.isDraggable && !isFullscreen && !isDragging}
|
|
|
|
$isDragging={isDragging}
|
2024-04-20 04:34:43 +08:00
|
|
|
id="cameraDock"
|
|
|
|
role="region"
|
|
|
|
draggable={cameraDock.isDraggable && !isFullscreen ? 'true' : undefined}
|
|
|
|
style={{
|
|
|
|
width: isIphone ? mobileWidth : isDesktopWidth,
|
|
|
|
height: isIphone ? mobileHeight : isDesktopHeight,
|
|
|
|
opacity: camOpacity,
|
2024-05-02 03:48:12 +08:00
|
|
|
background: 'none',
|
2024-04-20 04:34:43 +08:00
|
|
|
}}
|
|
|
|
>
|
|
|
|
<VideoProviderContainer
|
|
|
|
{...{
|
|
|
|
swapLayout,
|
|
|
|
cameraDock,
|
|
|
|
focusedId,
|
|
|
|
handleVideoFocus,
|
|
|
|
isGridEnabled,
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
</Styled.Draggable>
|
|
|
|
</Resizable>
|
|
|
|
</Draggable>
|
|
|
|
</Styled.ResizableWrapper>
|
|
|
|
</>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
interface WebcamContainerGraphqlProps {
|
|
|
|
audioModalIsOpen: boolean;
|
|
|
|
isLayoutSwapped: boolean;
|
|
|
|
layoutType: string;
|
|
|
|
paginationEnabled: boolean;
|
|
|
|
viewParticipantsWebcams: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
const WebcamContainerGraphql: React.FC<WebcamContainerGraphqlProps> = ({
|
|
|
|
audioModalIsOpen,
|
|
|
|
isLayoutSwapped,
|
|
|
|
layoutType,
|
|
|
|
paginationEnabled,
|
|
|
|
viewParticipantsWebcams,
|
|
|
|
}) => {
|
2024-05-02 03:48:12 +08:00
|
|
|
const fullscreen = layoutSelect((i: Layout) => i.fullscreen);
|
|
|
|
const isRTL = layoutSelect((i: Layout) => i.isRTL);
|
|
|
|
const cameraDockInput = layoutSelectInput((i: Input) => i.cameraDock);
|
|
|
|
const presentation = layoutSelectOutput((i: Output) => i.presentation);
|
|
|
|
const cameraDock = layoutSelectOutput((i: Output) => i.cameraDock);
|
2024-04-20 04:34:43 +08:00
|
|
|
const layoutContextDispatch = layoutDispatch();
|
|
|
|
const { data: presentationPageData } = useSubscription(CURRENT_PRESENTATION_PAGE_SUBSCRIPTION);
|
|
|
|
const presentationPage = presentationPageData?.pres_page_curr[0] || {};
|
|
|
|
const hasPresentation = !!presentationPage?.presentationId;
|
|
|
|
|
|
|
|
const swapLayout = !hasPresentation || isLayoutSwapped;
|
|
|
|
|
|
|
|
let floatingOverlay = false;
|
|
|
|
let hideOverlay = false;
|
|
|
|
|
|
|
|
if (swapLayout) {
|
|
|
|
floatingOverlay = true;
|
|
|
|
hideOverlay = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
const { cameraOptimalGridSize } = cameraDockInput;
|
|
|
|
const { display: displayPresentation } = presentation;
|
|
|
|
|
|
|
|
const { data: currentUserData } = useCurrentUser((user) => ({
|
|
|
|
presenter: user.presenter,
|
|
|
|
}));
|
|
|
|
|
|
|
|
const isGridEnabled = layoutType === LAYOUT_TYPE.VIDEO_FOCUS;
|
|
|
|
|
|
|
|
const { streams: videoUsers, gridUsers } = useVideoStreams(isGridEnabled, paginationEnabled, viewParticipantsWebcams);
|
|
|
|
|
2024-05-02 03:48:12 +08:00
|
|
|
let usersVideo: VideoItem[];
|
2024-04-20 04:34:43 +08:00
|
|
|
if (gridUsers.length > 0) {
|
2024-05-02 03:48:12 +08:00
|
|
|
usersVideo = [
|
|
|
|
...videoUsers,
|
|
|
|
...gridUsers,
|
|
|
|
];
|
2024-04-20 04:34:43 +08:00
|
|
|
} else {
|
|
|
|
usersVideo = videoUsers;
|
|
|
|
}
|
|
|
|
|
|
|
|
return !audioModalIsOpen && (usersVideo.length > 0 || isGridEnabled)
|
|
|
|
? (
|
|
|
|
<WebcamComponentGraphql
|
|
|
|
{...{
|
|
|
|
swapLayout,
|
|
|
|
usersVideo,
|
|
|
|
focusedId: cameraDock.focusedId,
|
|
|
|
cameraDock,
|
|
|
|
cameraOptimalGridSize,
|
|
|
|
layoutContextDispatch,
|
|
|
|
fullscreen,
|
2024-05-02 03:48:12 +08:00
|
|
|
isPresenter: currentUserData?.presenter ?? false,
|
2024-04-20 04:34:43 +08:00
|
|
|
displayPresentation,
|
|
|
|
isRTL,
|
|
|
|
isGridEnabled,
|
|
|
|
floatingOverlay,
|
|
|
|
hideOverlay,
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
)
|
|
|
|
: null;
|
|
|
|
};
|
|
|
|
|
2024-05-02 03:48:12 +08:00
|
|
|
type TrackerData = {
|
|
|
|
audioModalIsOpen: boolean;
|
|
|
|
paginationEnabled: boolean;
|
|
|
|
viewParticipantsWebcams: boolean;
|
|
|
|
};
|
|
|
|
|
|
|
|
type TrackerProps = {
|
|
|
|
isLayoutSwapped: boolean;
|
|
|
|
layoutType: string;
|
|
|
|
};
|
2024-04-20 04:34:43 +08:00
|
|
|
|
2024-05-02 03:48:12 +08:00
|
|
|
export default withTracker<TrackerData, TrackerProps>(() => {
|
|
|
|
const audioModalIsOpen = Session.get('audioModalIsOpen');
|
|
|
|
// @ts-expect-error -> Untyped object.
|
|
|
|
const { paginationEnabled } = Settings.application;
|
|
|
|
// @ts-expect-error -> Untyped object.
|
|
|
|
const { viewParticipantsWebcams } = Settings.dataSaving;
|
|
|
|
return {
|
|
|
|
audioModalIsOpen,
|
|
|
|
paginationEnabled,
|
|
|
|
viewParticipantsWebcams,
|
|
|
|
};
|
2024-04-20 04:34:43 +08:00
|
|
|
})(WebcamContainerGraphql);
|