mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-14 19:20:28 +03:00
support matrix.to links (#1849)
* support room via server params and eventId * change copy link to matrix.to links * display matrix.to links in messages as pill and stop generating url previews for them * improve editor mention to include viaServers and eventId * fix mention custom attributes * always try to open room in current space * jump to latest remove target eventId from url * add create direct search options to open/create dm with url
This commit is contained in:
parent
74dc76e22e
commit
5058136737
38 changed files with 781 additions and 476 deletions
|
|
@ -41,7 +41,7 @@ import {
|
|||
} from './pathUtils';
|
||||
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
||||
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
|
||||
import { Direct, DirectRouteRoomProvider } from './client/direct';
|
||||
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
||||
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
|
||||
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
||||
import { Notifications, Inbox, Invites } from './client/inbox';
|
||||
|
|
@ -160,7 +160,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
}
|
||||
>
|
||||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||
<Route path={_CREATE_PATH} element={<p>create</p>} />
|
||||
<Route path={_CREATE_PATH} element={<DirectCreate />} />
|
||||
<Route
|
||||
path={_ROOM_PATH}
|
||||
element={
|
||||
|
|
|
|||
33
src/app/pages/client/direct/DirectCreate.tsx
Normal file
33
src/app/pages/client/direct/DirectCreate.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { WelcomePage } from '../WelcomePage';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getDirectCreateSearchParams } from '../../pathSearchParam';
|
||||
import { getDirectPath, getDirectRoomPath } from '../../pathUtils';
|
||||
import { getDMRoomFor } from '../../../utils/matrix';
|
||||
import { openInviteUser } from '../../../../client/action/navigation';
|
||||
import { useDirectRooms } from './useDirectRooms';
|
||||
|
||||
export function DirectCreate() {
|
||||
const mx = useMatrixClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { userId } = getDirectCreateSearchParams(searchParams);
|
||||
const directs = useDirectRooms();
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
const room = getDMRoomFor(mx, userId);
|
||||
const { roomId } = room ?? {};
|
||||
if (roomId && directs.includes(roomId)) {
|
||||
navigate(getDirectRoomPath(roomId), { replace: true });
|
||||
} else {
|
||||
openInviteUser(undefined, userId);
|
||||
}
|
||||
} else {
|
||||
navigate(getDirectPath(), { replace: true });
|
||||
}
|
||||
}, [mx, navigate, directs, userId]);
|
||||
|
||||
return <WelcomePage />;
|
||||
}
|
||||
|
|
@ -10,12 +10,12 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||
const mx = useMatrixClient();
|
||||
const rooms = useDirectRooms();
|
||||
|
||||
const { roomIdOrAlias } = useParams();
|
||||
const { roomIdOrAlias, eventId } = useParams();
|
||||
const roomId = useSelectedRoom();
|
||||
const room = mx.getRoom(roomId);
|
||||
|
||||
if (!room || !rooms.includes(room.roomId)) {
|
||||
return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
|
||||
return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} eventId={eventId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './Direct';
|
||||
export * from './RoomProvider';
|
||||
export * from './DirectCreate';
|
||||
|
|
|
|||
|
|
@ -5,17 +5,25 @@ import { RoomProvider } from '../../../hooks/useRoom';
|
|||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||
import { useHomeRooms } from './useHomeRooms';
|
||||
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
||||
|
||||
export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||
const mx = useMatrixClient();
|
||||
const rooms = useHomeRooms();
|
||||
|
||||
const { roomIdOrAlias } = useParams();
|
||||
const { roomIdOrAlias, eventId } = useParams();
|
||||
const viaServers = useSearchParamsViaServers();
|
||||
const roomId = useSelectedRoom();
|
||||
const room = mx.getRoom(roomId);
|
||||
|
||||
if (!room || !rooms.includes(room.roomId)) {
|
||||
return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
|
||||
return (
|
||||
<JoinBeforeNavigate
|
||||
roomIdOrAlias={roomIdOrAlias!}
|
||||
eventId={eventId}
|
||||
viaServers={viaServers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ import {
|
|||
} from 'matrix-js-sdk';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getMxIdLocalPart, isRoomId, isUserId } from '../../../utils/matrix';
|
||||
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||
import { InboxNotificationsPathSearchParams } from '../../paths';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
|
|
@ -52,8 +53,13 @@ import {
|
|||
Username,
|
||||
} from '../../../components/message';
|
||||
import colorMXID from '../../../../util/colorMXID';
|
||||
import { getReactCustomHtmlParser } from '../../../plugins/react-custom-html-parser';
|
||||
import { openJoinAlias, openProfileViewer } from '../../../../client/action/navigation';
|
||||
import {
|
||||
factoryRenderLinkifyWithMention,
|
||||
getReactCustomHtmlParser,
|
||||
LINKIFY_OPTS,
|
||||
makeMentionCustomProps,
|
||||
renderMatrixMention,
|
||||
} from '../../../plugins/react-custom-html-parser';
|
||||
import { RenderMessageContent } from '../../../components/RenderMessageContent';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
|
|
@ -70,6 +76,8 @@ import { ContainerColor } from '../../../styles/ContainerColor.css';
|
|||
import { VirtualTile } from '../../../components/virtualizer';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { EncryptedContent } from '../../../features/room/message';
|
||||
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
|
||||
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
|
||||
|
||||
type RoomNotificationsGroup = {
|
||||
roomId: string;
|
||||
|
|
@ -181,36 +189,26 @@ function RoomNotificationsGroupComp({
|
|||
}: RoomNotificationsGroupProps) {
|
||||
const mx = useMatrixClient();
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
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, {
|
||||
handleSpoilerClick: (evt) => {
|
||||
const target = evt.currentTarget;
|
||||
if (target.getAttribute('aria-pressed') === 'true') {
|
||||
evt.stopPropagation();
|
||||
target.setAttribute('aria-pressed', 'false');
|
||||
target.style.cursor = 'initial';
|
||||
}
|
||||
},
|
||||
handleMentionClick: (evt) => {
|
||||
const target = evt.currentTarget;
|
||||
const mentionId = target.getAttribute('data-mention-id');
|
||||
if (typeof mentionId !== 'string') return;
|
||||
if (isUserId(mentionId)) {
|
||||
openProfileViewer(mentionId, room.roomId);
|
||||
return;
|
||||
}
|
||||
if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
|
||||
if (mx.getRoom(mentionId)?.isSpaceRoom()) navigateSpace(mentionId);
|
||||
else navigateRoom(mentionId);
|
||||
return;
|
||||
}
|
||||
openJoinAlias(mentionId);
|
||||
},
|
||||
getReactCustomHtmlParser(mx, room.roomId, {
|
||||
linkifyOpts,
|
||||
handleSpoilerClick: spoilerClickHandler,
|
||||
handleMentionClick: mentionClickHandler,
|
||||
}),
|
||||
[mx, room, navigateRoom, navigateSpace]
|
||||
[mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler]
|
||||
);
|
||||
|
||||
const renderMatrixEvent = useMatrixEventRenderer<[IRoomEvent, string, GetContentCallback]>(
|
||||
|
|
@ -229,6 +227,7 @@ function RoomNotificationsGroupComp({
|
|||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={urlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment
|
||||
/>
|
||||
);
|
||||
|
|
@ -287,6 +286,7 @@ function RoomNotificationsGroupComp({
|
|||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={urlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,13 +47,7 @@ import {
|
|||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import {
|
||||
getOriginBaseUrl,
|
||||
getSpaceLobbyPath,
|
||||
getSpacePath,
|
||||
joinPathComponent,
|
||||
withOriginBaseUrl,
|
||||
} from '../../pathUtils';
|
||||
import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils';
|
||||
import {
|
||||
SidebarAvatar,
|
||||
SidebarItem,
|
||||
|
|
@ -67,7 +61,7 @@ import {
|
|||
import { RoomUnreadProvider, RoomsUnreadProvider } from '../../../components/RoomUnreadProvider';
|
||||
import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace';
|
||||
import { UnreadBadge } from '../../../components/unread-badge';
|
||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix';
|
||||
import { RoomAvatar } from '../../../components/room-avatar';
|
||||
import { nameInitials, randomStr } from '../../../utils/common';
|
||||
import {
|
||||
|
|
@ -83,7 +77,6 @@ import { AccountDataEvent } from '../../../../types/matrix/accountData';
|
|||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
|
||||
import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder';
|
||||
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||||
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
|
||||
import { useRoomsUnread } from '../../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
||||
|
|
@ -91,6 +84,8 @@ import { markAsRead } from '../../../../client/action/notifications';
|
|||
import { copyToClipboard } from '../../../utils/dom';
|
||||
import { openInviteUser, openSpaceSettings } from '../../../../client/action/navigation';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { getMatrixToRoom } from '../../../plugins/matrix-to';
|
||||
import { getViaServers } from '../../../plugins/via-servers';
|
||||
|
||||
type SpaceMenuProps = {
|
||||
room: Room;
|
||||
|
|
@ -100,7 +95,6 @@ type SpaceMenuProps = {
|
|||
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
|
||||
({ room, requestClose, onUnpin }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const { hashRouter } = useClientConfig();
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
|
||||
|
|
@ -124,8 +118,9 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
|
|||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, room.roomId));
|
||||
copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), spacePath));
|
||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
||||
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
|
||||
requestClose();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useSpace } from '../../../hooks/useSpace';
|
|||
import { getAllParents } from '../../../utils/room';
|
||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
||||
|
||||
export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||
const mx = useMatrixClient();
|
||||
|
|
@ -16,7 +17,8 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const allRooms = useAtomValue(allRoomsAtom);
|
||||
|
||||
const { roomIdOrAlias } = useParams();
|
||||
const { roomIdOrAlias, eventId } = useParams();
|
||||
const viaServers = useSearchParamsViaServers();
|
||||
const roomId = useSelectedRoom();
|
||||
const room = mx.getRoom(roomId);
|
||||
|
||||
|
|
@ -26,7 +28,13 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||
!allRooms.includes(room.roomId) ||
|
||||
!getAllParents(roomToParents, room.roomId).has(space.roomId)
|
||||
) {
|
||||
return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
|
||||
return (
|
||||
<JoinBeforeNavigate
|
||||
roomIdOrAlias={roomIdOrAlias!}
|
||||
eventId={eventId}
|
||||
viaServers={viaServers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -34,15 +34,8 @@ import {
|
|||
NavItemContent,
|
||||
NavLink,
|
||||
} from '../../../components/nav';
|
||||
import {
|
||||
getOriginBaseUrl,
|
||||
getSpaceLobbyPath,
|
||||
getSpacePath,
|
||||
getSpaceRoomPath,
|
||||
getSpaceSearchPath,
|
||||
withOriginBaseUrl,
|
||||
} from '../../pathUtils';
|
||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||
import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils';
|
||||
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix';
|
||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||
import {
|
||||
useSpaceLobbySelected,
|
||||
|
|
@ -69,11 +62,12 @@ import { useRoomsUnread } from '../../../state/hooks/unread';
|
|||
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||
import { LeaveSpacePrompt } from '../../../components/leave-space-prompt';
|
||||
import { copyToClipboard } from '../../../utils/dom';
|
||||
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||||
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { getMatrixToRoom } from '../../../plugins/matrix-to';
|
||||
import { getViaServers } from '../../../plugins/via-servers';
|
||||
|
||||
type SpaceMenuProps = {
|
||||
room: Room;
|
||||
|
|
@ -81,7 +75,6 @@ type SpaceMenuProps = {
|
|||
};
|
||||
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const { hashRouter } = useClientConfig();
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
|
||||
|
|
@ -100,8 +93,9 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
|
|||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, room.roomId));
|
||||
copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), spacePath));
|
||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
||||
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
|
||||
requestClose();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { allRoomsAtom } from '../../../state/room-list/roomList';
|
|||
import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace';
|
||||
import { SpaceProvider } from '../../../hooks/useSpace';
|
||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
||||
|
||||
type RouteSpaceProviderProps = {
|
||||
children: ReactNode;
|
||||
|
|
@ -13,13 +14,15 @@ type RouteSpaceProviderProps = {
|
|||
export function RouteSpaceProvider({ children }: RouteSpaceProviderProps) {
|
||||
const mx = useMatrixClient();
|
||||
const joinedSpaces = useSpaces(mx, allRoomsAtom);
|
||||
|
||||
const { spaceIdOrAlias } = useParams();
|
||||
const viaServers = useSearchParamsViaServers();
|
||||
|
||||
const selectedSpaceId = useSelectedSpace();
|
||||
const space = mx.getRoom(selectedSpaceId);
|
||||
|
||||
if (!space || !joinedSpaces.includes(space.roomId)) {
|
||||
return <JoinBeforeNavigate roomIdOrAlias={spaceIdOrAlias ?? ''} />;
|
||||
return <JoinBeforeNavigate roomIdOrAlias={spaceIdOrAlias ?? ''} viaServers={viaServers} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
13
src/app/pages/pathSearchParam.ts
Normal file
13
src/app/pages/pathSearchParam.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { _RoomSearchParams, DirectCreateSearchParams } from './paths';
|
||||
|
||||
type SearchParamsGetter<T> = (searchParams: URLSearchParams) => T;
|
||||
|
||||
export const getRoomSearchParams: SearchParamsGetter<_RoomSearchParams> = (searchParams) => ({
|
||||
viaServers: searchParams.get('viaServers') ?? undefined,
|
||||
});
|
||||
|
||||
export const getDirectCreateSearchParams: SearchParamsGetter<DirectCreateSearchParams> = (
|
||||
searchParams
|
||||
) => ({
|
||||
userId: searchParams.get('userId') ?? undefined,
|
||||
});
|
||||
|
|
@ -35,6 +35,11 @@ export type _SearchPathSearchParams = {
|
|||
senders?: string;
|
||||
};
|
||||
export const _SEARCH_PATH = 'search/';
|
||||
|
||||
export type _RoomSearchParams = {
|
||||
/* comma separated string of servers */
|
||||
viaServers?: string;
|
||||
};
|
||||
export const _ROOM_PATH = ':roomIdOrAlias/:eventId?/';
|
||||
|
||||
export const HOME_PATH = '/home/';
|
||||
|
|
@ -44,6 +49,9 @@ export const HOME_SEARCH_PATH = `/home/${_SEARCH_PATH}`;
|
|||
export const HOME_ROOM_PATH = `/home/${_ROOM_PATH}`;
|
||||
|
||||
export const DIRECT_PATH = '/direct/';
|
||||
export type DirectCreateSearchParams = {
|
||||
userId?: string;
|
||||
};
|
||||
export const DIRECT_CREATE_PATH = `/direct/${_CREATE_PATH}`;
|
||||
export const DIRECT_ROOM_PATH = `/direct/${_ROOM_PATH}`;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue