move search messages to new sdk
This commit is contained in:
parent
8ef2ecb419
commit
7fa5ee9fc6
8 changed files with 363 additions and 348 deletions
|
|
@ -55,7 +55,7 @@ export function RenderMessageContent({
|
|||
}
|
||||
|
||||
if (eventType !== MessageEvent.RoomMessage) {
|
||||
return <UnsupportedContent />;
|
||||
return <UnsupportedContent eventType={eventType} />;
|
||||
}
|
||||
|
||||
const renderCaption = () => {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ import { useAtomValue } from 'jotai';
|
|||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SearchOrderBy } from 'matrix-js-sdk';
|
||||
import { SearchEventOrder } from 'neurone/lib/api/types';
|
||||
import { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { _SearchPathSearchParams } from '../../pages/paths';
|
||||
import { useSettings } from '../../state/hooks/settings';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
|
|
@ -18,10 +17,15 @@ import { useRooms } from '../../state/hooks/roomList';
|
|||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
||||
import { SearchResultGroup } from './SearchResultGroup';
|
||||
import { SearchGroupMessages, SearchResultGroup } from './SearchResultGroup';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { SearchFilters } from './SearchFilters';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { useObservableValue } from '../../hooks/useObservableValue';
|
||||
import { useRoomHeroes } from '../../hooks/storage/useRoomHeros';
|
||||
import { useGetRoomName } from '../../hooks/storage/useGetRoomName';
|
||||
import { useGetRoomAvatar } from '../../hooks/storage/useGetRoomAvatar';
|
||||
|
||||
const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
|
||||
useMemo(
|
||||
|
|
@ -49,9 +53,9 @@ export function MessageSearch({
|
|||
senders,
|
||||
scrollRef,
|
||||
}: MessageSearchProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const allRooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||
const allRooms = useRooms(ct, allRoomsAtom, mDirects);
|
||||
const { mediaAutoLoad, urlPreview, legacyUsernameColor, hour24Clock, dateFormatString } =
|
||||
useSettings();
|
||||
|
||||
|
|
@ -81,9 +85,11 @@ export function MessageSearch({
|
|||
const isGlobal = searchPathSearchParams.global === 'true';
|
||||
const defaultRooms = isGlobal ? undefined : rooms;
|
||||
|
||||
const { order } = searchPathSearchParams;
|
||||
|
||||
return {
|
||||
term: searchPathSearchParams.term,
|
||||
order: searchPathSearchParams.order ?? SearchOrderBy.Recent,
|
||||
order: order && order === 'rank' ? 'rank' : 'recent',
|
||||
rooms: searchParamRooms ?? defaultRooms,
|
||||
senders: searchParamsSenders ?? senders,
|
||||
};
|
||||
|
|
@ -159,7 +165,7 @@ export function MessageSearch({
|
|||
});
|
||||
};
|
||||
|
||||
const handleOrderChange = (order?: string) => {
|
||||
const handleOrderChange = (order?: SearchEventOrder) => {
|
||||
setSearchParams((prevParams) => {
|
||||
const newParams = new URLSearchParams(prevParams);
|
||||
newParams.delete('order');
|
||||
|
|
@ -184,6 +190,13 @@ export function MessageSearch({
|
|||
}
|
||||
}, [lastVItemIndex, lastGroupIndex, fetchNextPage, isFetchingNextPage, hasNextPage]);
|
||||
|
||||
const roomToTimeline = useObservableValue(ct.roomToTimeline);
|
||||
const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes(
|
||||
searchPathSearchParams.global === 'true' ? allRooms : rooms
|
||||
);
|
||||
const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value);
|
||||
const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="700">
|
||||
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
|
||||
|
|
@ -216,6 +229,7 @@ export function MessageSearch({
|
|||
onGlobalChange={handleGlobalChange}
|
||||
order={msgSearchParams.order}
|
||||
onOrderChange={handleOrderChange}
|
||||
getRoomName={getRoomName}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
|
@ -269,8 +283,6 @@ export function MessageSearch({
|
|||
{vItems.map((vItem) => {
|
||||
const group = groups[vItem.index];
|
||||
if (!group) return null;
|
||||
const groupRoom = mx.getRoom(group.roomId);
|
||||
if (!groupRoom) return null;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
|
|
@ -280,16 +292,25 @@ export function MessageSearch({
|
|||
key={vItem.index}
|
||||
>
|
||||
<SearchResultGroup
|
||||
room={groupRoom}
|
||||
highlights={highlights}
|
||||
roomId={group.roomId}
|
||||
items={group.items}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={urlPreview}
|
||||
onOpen={navigateRoom}
|
||||
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
getRoomName={getRoomName}
|
||||
getRoomAvatar={getRoomAvatar}
|
||||
>
|
||||
<SearchGroupMessages
|
||||
roomId={group.roomId}
|
||||
timeline={roomToTimeline.get(group.roomId)}
|
||||
highlights={highlights}
|
||||
items={group.items}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={urlPreview}
|
||||
onOpen={navigateRoom}
|
||||
legacyUsernameColor={legacyUsernameColor || mDirects.has(group.roomId)}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
getRoomName={getRoomName}
|
||||
/>
|
||||
</SearchResultGroup>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -25,11 +25,9 @@ import {
|
|||
Badge,
|
||||
RectCords,
|
||||
} from 'folds';
|
||||
import { SearchOrderBy } from 'matrix-js-sdk';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getRoomIconSrc } from '../../utils/room';
|
||||
import { SearchEventOrder } from 'neurone/lib/api/types';
|
||||
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
||||
import {
|
||||
SearchItemStrGetter,
|
||||
|
|
@ -39,16 +37,19 @@ import {
|
|||
import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { GetRoomNameCallback } from '../../hooks/storage/useGetRoomName';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { RoomIcon } from '../../components/room-avatar';
|
||||
|
||||
type OrderButtonProps = {
|
||||
order?: string;
|
||||
onChange: (order?: string) => void;
|
||||
order?: SearchEventOrder;
|
||||
onChange: (order?: SearchEventOrder) => void;
|
||||
};
|
||||
function OrderButton({ order, onChange }: OrderButtonProps) {
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const rankOrder = order === SearchOrderBy.Rank;
|
||||
const rankOrder = order === 'rank';
|
||||
|
||||
const setOrder = (o?: string) => {
|
||||
const setOrder = (o?: SearchEventOrder) => {
|
||||
setMenuAnchor(undefined);
|
||||
onChange(o);
|
||||
};
|
||||
|
|
@ -86,7 +87,7 @@ function OrderButton({ order, onChange }: OrderButtonProps) {
|
|||
<Text size="T300">Recent</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => setOrder(SearchOrderBy.Rank)}
|
||||
onClick={() => setOrder('rank')}
|
||||
variant="Surface"
|
||||
size="300"
|
||||
radii="300"
|
||||
|
|
@ -125,16 +126,22 @@ type SelectRoomButtonProps = {
|
|||
roomList: string[];
|
||||
selectedRooms?: string[];
|
||||
onChange: (rooms?: string[]) => void;
|
||||
getRoomName: GetRoomNameCallback;
|
||||
};
|
||||
function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
|
||||
const mx = useMatrixClient();
|
||||
function SelectRoomButton({
|
||||
roomList,
|
||||
selectedRooms,
|
||||
onChange,
|
||||
getRoomName,
|
||||
}: SelectRoomButtonProps) {
|
||||
const ct = useClient();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [localSelected, setLocalSelected] = useState(selectedRooms);
|
||||
|
||||
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
|
||||
(rId) => mx.getRoom(rId)?.name ?? rId,
|
||||
[mx]
|
||||
(rId) => getRoomName(rId) ?? 'Unknown',
|
||||
[getRoomName]
|
||||
);
|
||||
|
||||
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
|
||||
|
|
@ -142,7 +149,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
|||
getRoomNameStr,
|
||||
SEARCH_OPTS
|
||||
);
|
||||
const rooms = Array.from(searchResult?.items ?? roomList).sort(factoryRoomIdByAtoZ(mx));
|
||||
const rooms = Array.from(searchResult?.items ?? roomList).sort(factoryRoomIdByAtoZ(getRoomName));
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rooms.length,
|
||||
|
|
@ -253,8 +260,6 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
|||
>
|
||||
{vItems.map((vItem) => {
|
||||
const roomId = rooms[vItem.index];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const selected = localSelected?.includes(roomId);
|
||||
|
||||
return (
|
||||
|
|
@ -272,14 +277,16 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
|||
radii="300"
|
||||
aria-pressed={selected}
|
||||
before={
|
||||
<Icon
|
||||
<RoomIcon
|
||||
size="50"
|
||||
src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())}
|
||||
roomType={ct.roomToType.read.get(roomId)}
|
||||
joinRule={ct.roomToJoinRule.read.get(roomId)}
|
||||
filled
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text truncate size="T300">
|
||||
{room.name}
|
||||
{getRoomName(roomId) ?? 'Unknown'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</VirtualTile>
|
||||
|
|
@ -333,8 +340,9 @@ type SearchFiltersProps = {
|
|||
onSelectedRoomsChange: (selectedRooms?: string[]) => void;
|
||||
global?: boolean;
|
||||
onGlobalChange: (global?: boolean) => void;
|
||||
order?: string;
|
||||
onOrderChange: (order?: string) => void;
|
||||
order?: SearchEventOrder;
|
||||
onOrderChange: (order?: SearchEventOrder) => void;
|
||||
getRoomName: GetRoomNameCallback;
|
||||
};
|
||||
export function SearchFilters({
|
||||
defaultRoomsFilterName,
|
||||
|
|
@ -346,8 +354,9 @@ export function SearchFilters({
|
|||
order,
|
||||
onGlobalChange,
|
||||
onOrderChange,
|
||||
getRoomName,
|
||||
}: SearchFiltersProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
|
|
@ -379,29 +388,30 @@ export function SearchFilters({
|
|||
variant="Surface"
|
||||
size="300"
|
||||
/>
|
||||
{selectedRooms?.map((roomId) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
|
||||
return (
|
||||
<Chip
|
||||
key={roomId}
|
||||
variant="Success"
|
||||
onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))}
|
||||
radii="Pill"
|
||||
before={
|
||||
<Icon size="50" src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())} />
|
||||
}
|
||||
after={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
<Text size="T200">{room.name}</Text>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
{selectedRooms?.map((roomId) => (
|
||||
<Chip
|
||||
key={roomId}
|
||||
variant="Success"
|
||||
onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))}
|
||||
radii="Pill"
|
||||
before={
|
||||
<RoomIcon
|
||||
size="50"
|
||||
roomType={ct.roomToType.read.get(roomId)}
|
||||
joinRule={ct.roomToJoinRule.read.get(roomId)}
|
||||
filled
|
||||
/>
|
||||
}
|
||||
after={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
<Text size="T200">{getRoomName(roomId)}</Text>
|
||||
</Chip>
|
||||
))}
|
||||
<SelectRoomButton
|
||||
roomList={roomList}
|
||||
selectedRooms={selectedRooms}
|
||||
onChange={onSelectedRoomsChange}
|
||||
getRoomName={getRoomName}
|
||||
/>
|
||||
<Box grow="Yes" data-spacing-node />
|
||||
<OrderButton order={order} onChange={onOrderChange} />
|
||||
|
|
|
|||
|
|
@ -1,46 +1,34 @@
|
|||
/* eslint-disable react/destructuring-assignment */
|
||||
import React, { MouseEventHandler, useMemo } from 'react';
|
||||
import { IEventWithRoomId, JoinRule, RelationType, Room } from 'matrix-js-sdk';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import React, { MouseEventHandler, ReactNode, useMemo } from 'react';
|
||||
import { Avatar, Box, Chip, Header, Icon, Icons, Text, config } from 'folds';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import {
|
||||
factoryRenderLinkifyWithMention,
|
||||
getReactCustomHtmlParser,
|
||||
LINKIFY_OPTS,
|
||||
makeHighlightRegex,
|
||||
makeMentionCustomProps,
|
||||
renderMatrixMention,
|
||||
} from '../../plugins/react-custom-html-parser';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import { makeHighlightRegex } from '../../plugins/react-custom-html-parser';
|
||||
import {
|
||||
AvatarBase,
|
||||
ImageContent,
|
||||
MSticker,
|
||||
ModernLayout,
|
||||
RedactedContent,
|
||||
Reply,
|
||||
Time,
|
||||
Username,
|
||||
UsernameBold,
|
||||
} from '../../components/message';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
import { Image } from '../../components/media';
|
||||
import { ImageViewer } from '../../components/image-viewer';
|
||||
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../utils/room';
|
||||
import { getLatestEditEvent } from '../../utils/room';
|
||||
import { ResultItem } from './useMessageSearch';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
||||
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||
import {
|
||||
PowerLevelsContextProvider,
|
||||
usePowerLevels,
|
||||
usePowerLevelsContext,
|
||||
usePowerLevelsState,
|
||||
} from '../../hooks/usePowerLevels';
|
||||
import {
|
||||
PowerLevelTagsContextProvider,
|
||||
usePowerLevelTags,
|
||||
usePowerLevelTagsContext,
|
||||
usePowerLevelTagsState,
|
||||
} from '../../hooks/usePowerLevelTags';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { PowerIcon } from '../../components/power';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
|
@ -51,279 +39,256 @@ import {
|
|||
} from '../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { mxcToHttp } from '../../../client/mxcToHttp';
|
||||
import {
|
||||
RoomMembersAPIProvider,
|
||||
useRoomMembers,
|
||||
useRoomMembersAPI,
|
||||
} from '../../hooks/useRoomMembers';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
import { GetRoomNameCallback } from '../../hooks/storage/useGetRoomName';
|
||||
import { GetRoomAvatarCallback } from '../../hooks/storage/useGetRoomAvatar';
|
||||
import { Timeline } from '../../../client/room';
|
||||
import { useObservableValueOptionally } from '../../hooks/useObservableValue';
|
||||
import { useMessageParserOptions } from '../../hooks/useMessageParserOptions';
|
||||
import { MessageContent } from '../../components/MessageContent';
|
||||
import { rContent } from '../../../client/reader';
|
||||
|
||||
type SearchResultGroupProps = {
|
||||
room: Room;
|
||||
highlights: string[];
|
||||
type SearchGroupMessagesProps = {
|
||||
roomId: string;
|
||||
timeline?: Timeline;
|
||||
items: ResultItem[];
|
||||
highlights: string[];
|
||||
mediaAutoLoad?: boolean;
|
||||
urlPreview?: boolean;
|
||||
onOpen: (roomId: string, eventId: string) => void;
|
||||
legacyUsernameColor?: boolean;
|
||||
hour24Clock: boolean;
|
||||
dateFormatString: string;
|
||||
getRoomName: GetRoomNameCallback;
|
||||
onOpen: (roomId: string, eventId: string) => void;
|
||||
};
|
||||
export function SearchResultGroup({
|
||||
room,
|
||||
highlights,
|
||||
export function SearchGroupMessages({
|
||||
roomId,
|
||||
timeline,
|
||||
items,
|
||||
highlights,
|
||||
mediaAutoLoad,
|
||||
urlPreview,
|
||||
onOpen,
|
||||
legacyUsernameColor,
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
}: SearchResultGroupProps) {
|
||||
const mx = useMatrixClient();
|
||||
getRoomName,
|
||||
onOpen,
|
||||
}: SearchGroupMessagesProps) {
|
||||
const ct = useClient();
|
||||
const membersAPI = useRoomMembersAPI();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
|
||||
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const redacted = useObservableValueOptionally(timeline?.relations.redacted);
|
||||
const edits = useObservableValueOptionally(timeline?.relations.edits.eventToRelations);
|
||||
|
||||
const creators = useRoomCreators(roomId);
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const getMemberPowerTag = useGetMemberPowerTag(creators, powerLevels);
|
||||
const creatorsTag = useRoomCreatorsTag();
|
||||
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
||||
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
||||
const powerLevelTags = usePowerLevelTagsContext();
|
||||
|
||||
const theme = useTheme();
|
||||
const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags);
|
||||
|
||||
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
||||
const spoilerClickHandler = useSpoilerClickHandler();
|
||||
|
||||
const linkifyOpts = useMemo<LinkifyOpts>(
|
||||
() => ({
|
||||
...LINKIFY_OPTS,
|
||||
render: factoryRenderLinkifyWithMention((href) =>
|
||||
renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
|
||||
),
|
||||
}),
|
||||
[mx, room, mentionClickHandler]
|
||||
);
|
||||
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
|
||||
() =>
|
||||
getReactCustomHtmlParser(mx, room.roomId, {
|
||||
linkifyOpts,
|
||||
highlightRegex,
|
||||
useAuthentication,
|
||||
handleSpoilerClick: spoilerClickHandler,
|
||||
handleMentionClick: mentionClickHandler,
|
||||
}),
|
||||
[
|
||||
mx,
|
||||
room,
|
||||
linkifyOpts,
|
||||
highlightRegex,
|
||||
mentionClickHandler,
|
||||
spoilerClickHandler,
|
||||
useAuthentication,
|
||||
]
|
||||
);
|
||||
|
||||
const renderMatrixEvent = useMatrixEventRenderer<[IEventWithRoomId, string, GetContentCallback]>(
|
||||
{
|
||||
[MessageEvent.RoomMessage]: (event, displayName, getContent) => {
|
||||
if (event.unsigned?.redacted_because) {
|
||||
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<RenderMessageContent
|
||||
displayName={displayName}
|
||||
msgType={event.content.msgtype ?? ''}
|
||||
ts={event.origin_server_ts}
|
||||
getContent={getContent}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={urlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
highlightRegex={highlightRegex}
|
||||
outlineAttachment
|
||||
/>
|
||||
);
|
||||
},
|
||||
[MessageEvent.Reaction]: (event, displayName, getContent) => {
|
||||
if (event.unsigned?.redacted_because) {
|
||||
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
|
||||
}
|
||||
return (
|
||||
<MSticker
|
||||
content={getContent()}
|
||||
renderImageContent={(props) => (
|
||||
<ImageContent
|
||||
{...props}
|
||||
autoPlay={mediaAutoLoad}
|
||||
renderImage={(p) => <Image {...p} loading="lazy" />}
|
||||
renderViewer={(p) => <ImageViewer {...p} />}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[StateEvent.RoomTombstone]: (event) => {
|
||||
const { content } = event;
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T400" priority="300">
|
||||
Room Tombstone. {content.body}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
(event) => {
|
||||
if (event.unsigned?.redacted_because) {
|
||||
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
|
||||
}
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T400" priority="300">
|
||||
<code className={customHtmlCss.Code}>{event.type}</code>
|
||||
{' event'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const { linkifyOpts, htmlReactParserOptions } = useMessageParserOptions(
|
||||
roomId,
|
||||
getRoomName,
|
||||
membersAPI.getName,
|
||||
highlightRegex
|
||||
);
|
||||
|
||||
const handleOpenClick: MouseEventHandler = (evt) => {
|
||||
const eventId = evt.currentTarget.getAttribute('data-event-id');
|
||||
if (!eventId) return;
|
||||
onOpen(room.roomId, eventId);
|
||||
onOpen(roomId, eventId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
{items.map((item) => {
|
||||
const { event } = item;
|
||||
|
||||
const displayName = membersAPI.getName(event.sender);
|
||||
|
||||
const senderAvatarMxc = membersAPI.getAvatar(event.sender);
|
||||
|
||||
const memberPowerTag = getMemberPowerTag(event.sender);
|
||||
const tagColor = memberPowerTag?.color
|
||||
? accessibleTagColors?.get(memberPowerTag.color)
|
||||
: undefined;
|
||||
const tagIconSrc = memberPowerTag?.icon
|
||||
? getPowerTagIconSrc(ct, useAuthentication, memberPowerTag.icon)
|
||||
: undefined;
|
||||
|
||||
const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor;
|
||||
|
||||
const editEvents = edits?.get(event.event_id);
|
||||
const editedEvent = editEvents && getLatestEditEvent(event.sender, editEvents, redacted);
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
key={event.event_id}
|
||||
style={{ padding: `${config.space.S300} ${config.space.S400}` }}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
>
|
||||
<ModernLayout
|
||||
before={
|
||||
<AvatarBase>
|
||||
<Avatar size="300">
|
||||
<UserAvatar
|
||||
userId={event.sender}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? mxcToHttp(
|
||||
ct.session.baseUrl,
|
||||
senderAvatarMxc,
|
||||
useAuthentication
|
||||
).thumbnail({ width: 96, height: 96, method: 'crop' })
|
||||
: undefined
|
||||
}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</AvatarBase>
|
||||
}
|
||||
>
|
||||
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Username style={{ color: usernameColor }}>
|
||||
<Text as="span" truncate>
|
||||
<UsernameBold>{displayName}</UsernameBold>
|
||||
</Text>
|
||||
</Username>
|
||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||
</Box>
|
||||
<Time
|
||||
ts={event.origin_server_ts}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
</Box>
|
||||
<Box shrink="No" gap="200" alignItems="Center">
|
||||
<Chip
|
||||
data-event-id={event.event_id}
|
||||
onClick={handleOpenClick}
|
||||
variant="Secondary"
|
||||
radii="400"
|
||||
>
|
||||
<Text size="T200">Open</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
{redacted?.has(event.event_id) ? (
|
||||
<RedactedContent />
|
||||
) : (
|
||||
<MessageContent
|
||||
event={event}
|
||||
editedEvent={editedEvent}
|
||||
roomId={roomId}
|
||||
decryptionManager={ct.decryption}
|
||||
>
|
||||
{(contentEvent) => (
|
||||
<RenderMessageContent
|
||||
displayName={membersAPI.getName(event.sender)}
|
||||
eventType={contentEvent.type}
|
||||
ts={event.origin_server_ts}
|
||||
edited={!!editedEvent}
|
||||
content={rContent.newContent(contentEvent.content) ?? contentEvent.content}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={urlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
highlightRegex={highlightRegex}
|
||||
/>
|
||||
)}
|
||||
</MessageContent>
|
||||
)}
|
||||
</ModernLayout>
|
||||
</SequenceCard>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type SearchResultGroupProps = {
|
||||
roomId: string;
|
||||
items: ResultItem[];
|
||||
getRoomName: GetRoomNameCallback;
|
||||
getRoomAvatar: GetRoomAvatarCallback;
|
||||
children: ReactNode;
|
||||
};
|
||||
export function SearchResultGroup({
|
||||
roomId,
|
||||
items,
|
||||
getRoomName,
|
||||
getRoomAvatar,
|
||||
children,
|
||||
}: SearchResultGroupProps) {
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const users = useMemo(() => Array.from(new Set(items.map((i) => i.event.sender))), [items]);
|
||||
|
||||
const avatarMxc = getRoomAvatar(roomId);
|
||||
|
||||
const powerLevelStateResult = usePowerLevelsState(roomId);
|
||||
const powerLevels = usePowerLevels(powerLevelStateResult.value);
|
||||
|
||||
const powerLevelTagsStateResult = usePowerLevelTagsState(roomId);
|
||||
const powerLevelTags = usePowerLevelTags(powerLevels, powerLevelTagsStateResult.value);
|
||||
|
||||
const membersAPI = useRoomMembers(roomId, users);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="200">
|
||||
<Header size="300">
|
||||
<Box gap="200" grow="Yes">
|
||||
<Avatar size="200" radii="300">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={getRoomAvatarUrl(mx, room, 96, useAuthentication)}
|
||||
alt={room.name}
|
||||
roomId={roomId}
|
||||
src={
|
||||
avatarMxc
|
||||
? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).thumbnail({
|
||||
width: 48,
|
||||
height: 48,
|
||||
method: 'crop',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
alt={getRoomName(roomId)}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
size="50"
|
||||
roomType={room.getType()}
|
||||
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
|
||||
roomType={ct.roomToType.read.get(roomId)}
|
||||
joinRule={ct.roomToJoinRule.read.get(roomId)}
|
||||
filled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H4" truncate>
|
||||
{room.name}
|
||||
{getRoomName(roomId) ?? 'Unknown'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box direction="Column" gap="100">
|
||||
{items.map((item) => {
|
||||
const { event } = item;
|
||||
|
||||
const displayName =
|
||||
getMemberDisplayName(room, event.sender) ??
|
||||
getMxIdLocalPart(event.sender) ??
|
||||
event.sender;
|
||||
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
|
||||
|
||||
const relation = event.content['m.relates_to'];
|
||||
const mainEventId =
|
||||
relation?.rel_type === RelationType.Replace ? relation.event_id : event.event_id;
|
||||
|
||||
const getContent = (() =>
|
||||
event.content['m.new_content'] ?? event.content) as GetContentCallback;
|
||||
|
||||
const replyEventId = relation?.['m.in_reply_to']?.event_id;
|
||||
const threadRootId =
|
||||
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
|
||||
|
||||
const memberPowerTag = getMemberPowerTag(event.sender);
|
||||
const tagColor = memberPowerTag?.color
|
||||
? accessibleTagColors?.get(memberPowerTag.color)
|
||||
: undefined;
|
||||
const tagIconSrc = memberPowerTag?.icon
|
||||
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
|
||||
: undefined;
|
||||
|
||||
const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor;
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
key={event.event_id}
|
||||
style={{ padding: config.space.S400 }}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
>
|
||||
<ModernLayout
|
||||
before={
|
||||
<AvatarBase>
|
||||
<Avatar size="300">
|
||||
<UserAvatar
|
||||
userId={event.sender}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? mxcUrlToHttp(
|
||||
mx,
|
||||
senderAvatarMxc,
|
||||
useAuthentication,
|
||||
48,
|
||||
48,
|
||||
'crop'
|
||||
) ?? undefined
|
||||
: undefined
|
||||
}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</AvatarBase>
|
||||
}
|
||||
>
|
||||
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Username style={{ color: usernameColor }}>
|
||||
<Text as="span" truncate>
|
||||
<UsernameBold>{displayName}</UsernameBold>
|
||||
</Text>
|
||||
</Username>
|
||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||
</Box>
|
||||
<Time
|
||||
ts={event.origin_server_ts}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
</Box>
|
||||
<Box shrink="No" gap="200" alignItems="Center">
|
||||
<Chip
|
||||
data-event-id={mainEventId}
|
||||
onClick={handleOpenClick}
|
||||
variant="Secondary"
|
||||
radii="400"
|
||||
>
|
||||
<Text size="T200">Open</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
{replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenClick}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessibleTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor}
|
||||
/>
|
||||
)}
|
||||
{renderMatrixEvent(event.type, false, event, displayName, getContent)}
|
||||
</ModernLayout>
|
||||
</SequenceCard>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<RoomMembersAPIProvider value={membersAPI}>
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<PowerLevelTagsContextProvider value={powerLevelTags}>
|
||||
{children}
|
||||
</PowerLevelTagsContextProvider>
|
||||
</PowerLevelsContextProvider>
|
||||
</RoomMembersAPIProvider>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import {
|
||||
IEventWithRoomId,
|
||||
IResultContext,
|
||||
ISearchRequestBody,
|
||||
ISearchResponse,
|
||||
ISearchResult,
|
||||
SearchOrderBy,
|
||||
} from 'matrix-js-sdk';
|
||||
import { useCallback } from 'react';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import {
|
||||
ClientEvent,
|
||||
SearchEventContext,
|
||||
SearchEventOrder,
|
||||
SearchResult,
|
||||
} from 'neurone/lib/api/types';
|
||||
import { postSearch, PostSearchBody, PostSearchOK } from 'neurone/lib/api/matrix/client/v3/search';
|
||||
import { parseMatrixResp } from 'neurone/lib/matrix-request';
|
||||
import { useClient } from '../../hooks/useClient';
|
||||
|
||||
export type ResultItem = {
|
||||
rank: number;
|
||||
event: IEventWithRoomId;
|
||||
context: IResultContext;
|
||||
event: ClientEvent;
|
||||
context: SearchEventContext;
|
||||
};
|
||||
|
||||
export type ResultGroup = {
|
||||
|
|
@ -20,21 +20,24 @@ export type ResultGroup = {
|
|||
items: ResultItem[];
|
||||
};
|
||||
|
||||
export type SearchResult = {
|
||||
export type MessageSearchResult = {
|
||||
nextToken?: string;
|
||||
highlights: string[];
|
||||
groups: ResultGroup[];
|
||||
};
|
||||
|
||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||
const groupSearchResult = (results: SearchResult[]): ResultGroup[] => {
|
||||
const groups: ResultGroup[] = [];
|
||||
|
||||
results.forEach((item) => {
|
||||
const roomId = item.result.room_id;
|
||||
const { rank, result, context } = item;
|
||||
if (!result || typeof rank !== 'number' || !context) return;
|
||||
|
||||
const roomId = result.room_id;
|
||||
const resultItem: ResultItem = {
|
||||
rank: item.rank,
|
||||
event: item.result,
|
||||
context: item.context,
|
||||
rank,
|
||||
event: result,
|
||||
context,
|
||||
};
|
||||
|
||||
const lastAddedGroup: ResultGroup | undefined = groups[groups.length - 1];
|
||||
|
|
@ -51,10 +54,10 @@ const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
|||
return groups;
|
||||
};
|
||||
|
||||
const parseSearchResult = (result: ISearchResponse): SearchResult => {
|
||||
const parseSearchResult = (result: PostSearchOK): MessageSearchResult => {
|
||||
const roomEvents = result.search_categories.room_events;
|
||||
|
||||
const searchResult: SearchResult = {
|
||||
const searchResult: MessageSearchResult = {
|
||||
nextToken: roomEvents?.next_batch,
|
||||
highlights: roomEvents?.highlights ?? [],
|
||||
groups: groupSearchResult(roomEvents?.results ?? []),
|
||||
|
|
@ -65,12 +68,12 @@ const parseSearchResult = (result: ISearchResponse): SearchResult => {
|
|||
|
||||
export type MessageSearchParams = {
|
||||
term?: string;
|
||||
order?: string;
|
||||
order?: SearchEventOrder;
|
||||
rooms?: string[];
|
||||
senders?: string[];
|
||||
};
|
||||
export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const { term, order, rooms, senders } = params;
|
||||
|
||||
const searchMessages = useCallback(
|
||||
|
|
@ -82,7 +85,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
|||
};
|
||||
const limit = 20;
|
||||
|
||||
const requestBody: ISearchRequestBody = {
|
||||
const requestBody: PostSearchBody = {
|
||||
search_categories: {
|
||||
room_events: {
|
||||
event_context: {
|
||||
|
|
@ -96,19 +99,28 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
|||
senders,
|
||||
},
|
||||
include_state: false,
|
||||
order_by: order as SearchOrderBy.Recent,
|
||||
order_by: order,
|
||||
search_term: term,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const r = await mx.search({
|
||||
body: requestBody,
|
||||
next_batch: nextBatch === '' ? undefined : nextBatch,
|
||||
});
|
||||
return parseSearchResult(r);
|
||||
const resp = await ct.requestAuthJSON(
|
||||
postSearch({
|
||||
searchParams: { next_batch: nextBatch === '' ? undefined : nextBatch },
|
||||
body: requestBody,
|
||||
})
|
||||
);
|
||||
|
||||
const [err, result] = await parseMatrixResp<PostSearchOK>(resp);
|
||||
|
||||
if (err) {
|
||||
throw new Error(err.data.error ?? err.data.errcode);
|
||||
}
|
||||
|
||||
return parseSearchResult(result);
|
||||
},
|
||||
[mx, term, order, rooms, senders]
|
||||
[ct, term, order, rooms, senders]
|
||||
);
|
||||
|
||||
return searchMessages;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import { useMediaAuthentication } from './useMediaAuthentication';
|
|||
export const useMessageParserOptions = (
|
||||
roomId: string,
|
||||
getRoomName: GetRoomNameCallback,
|
||||
getMemberName: GetRoomMemberNameCallback
|
||||
getMemberName: GetRoomMemberNameCallback,
|
||||
highlightRegex?: RegExp
|
||||
) => {
|
||||
const ct = useClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
|
@ -49,6 +50,7 @@ export const useMessageParserOptions = (
|
|||
getReactCustomHtmlParser(ct, roomId, {
|
||||
linkifyOpts,
|
||||
useAuthentication,
|
||||
highlightRegex,
|
||||
handleSpoilerClick: spoilerClickHandler,
|
||||
handleMentionClick: mentionClickHandler,
|
||||
getMemberName,
|
||||
|
|
@ -58,6 +60,7 @@ export const useMessageParserOptions = (
|
|||
ct,
|
||||
roomId,
|
||||
linkifyOpts,
|
||||
highlightRegex,
|
||||
spoilerClickHandler,
|
||||
mentionClickHandler,
|
||||
useAuthentication,
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||
<Route path={_CREATE_PATH} element={<HomeCreateRoom />} />
|
||||
<Route path={_JOIN_PATH} element={<p>join</p>} />
|
||||
{/* <Route path={_SEARCH_PATH} element={<HomeSearch />} /> */}
|
||||
<Route path={_SEARCH_PATH} element={<HomeSearch />} />
|
||||
<Route
|
||||
path={_ROOM_PATH}
|
||||
element={
|
||||
|
|
@ -187,7 +187,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
/>
|
||||
)}
|
||||
<Route path={_LOBBY_PATH} element={<Lobby />} />
|
||||
{/*<Route path={_SEARCH_PATH} element={<SpaceSearch />} /> */}
|
||||
<Route path={_SEARCH_PATH} element={<SpaceSearch />} />
|
||||
<Route
|
||||
path={_ROOM_PATH}
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -8,22 +8,22 @@ import { useRecursiveChildRoomScopeFactory, useSpaceChildren } from '../../../st
|
|||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
|
||||
export function SpaceSearch() {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const space = useSpace();
|
||||
const spaceId = useSpace();
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const rooms = useSpaceChildren(
|
||||
allRoomsAtom,
|
||||
space.roomId,
|
||||
useRecursiveChildRoomScopeFactory(mx, mDirects, roomToParents)
|
||||
spaceId,
|
||||
useRecursiveChildRoomScopeFactory(ct, mDirects, roomToParents)
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -55,7 +55,11 @@ export function SpaceSearch() {
|
|||
<PageContent>
|
||||
<PageContentCenter>
|
||||
<MessageSearch
|
||||
defaultRoomsFilterName={space.name}
|
||||
defaultRoomsFilterName={
|
||||
ct.roomToName.read.get(spaceId) ??
|
||||
ct.roomToAlias.read.get(spaceId) ??
|
||||
'Unknown Space'
|
||||
}
|
||||
allowGlobal
|
||||
rooms={rooms}
|
||||
scrollRef={scrollRef}
|
||||
|
|
|
|||
Loading…
Reference in a new issue