Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Friends UI Navigation #421

Merged
merged 8 commits into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pandora-client-web/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"react/no-unstable-nested-components": "error",
"react/jsx-no-script-url": "error",
// Warnings
"no-alert": "warn",
"react/default-props-match-prop-types": "warn",
"react/no-typos": "warn",
"react/prefer-stateless-function": "warn",
Expand Down
7 changes: 6 additions & 1 deletion pandora-client-web/src/components/chatroom/chatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useDirectoryConnector } from '../gameContext/directoryConnectorContextP
import { Select } from '../common/select/select';
import settingsIcon from '../../assets/icons/setting.svg';
import { z } from 'zod';
import { useNavigate } from 'react-router';

type Editing = {
target: number;
Expand Down Expand Up @@ -209,6 +210,7 @@ function TextAreaImpl({ messagesDiv, scrollMessagesView }: {

const directoryConnector = useDirectoryConnector();
const shardConnector = useShardConnector();
const navigate = useNavigate();
AssertNotNullable(shardConnector);

/**
Expand All @@ -227,7 +229,8 @@ function TextAreaImpl({ messagesDiv, scrollMessagesView }: {
chatRoom,
messageSender: sender,
inputHandlerContext: chatInput,
}), [chatInput, chatRoom, directoryConnector, sender, shardConnector]);
navigate,
}), [chatInput, chatRoom, directoryConnector, navigate, sender, shardConnector]);

const inputEnd = useEvent(() => {
if (timeout.current) {
Expand Down Expand Up @@ -596,6 +599,7 @@ function AutoCompleteHint(): ReactElement | null {

const directoryConnector = useDirectoryConnector();
const shardConnector = useShardConnector();
const navigate = useNavigate();
AssertNotNullable(shardConnector);
if (!autocompleteHint?.result || !allowCommands)
return null;
Expand Down Expand Up @@ -645,6 +649,7 @@ function AutoCompleteHint(): ReactElement | null {
chatRoom,
messageSender: sender,
inputHandlerContext: chatInput,
navigate,
});

setAutocompleteHint({
Expand Down
13 changes: 13 additions & 0 deletions pandora-client-web/src/components/chatroom/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,19 @@ export const COMMANDS: readonly IClientCommand[] = [
// return target ? { status: 'whisper', target } : { status: 'none' };
// },
},
{
key: ['dm'],
description: 'Switches to direct message screen',
longDescription: 'Switches to direct message screen with the selected <target> character.' + LONGDESC_THIRD_PERSON,
usage: '<target>',
handler: CreateClientCommand()
.argument('target', CommandSelectorCharacter({ allowSelf: 'none' }))
.handler(({ directoryConnector, navigate }, { target }) => {
directoryConnector.directMessageHandler.setSelected(target.data.accountId);
navigate('/relationships/DMs');
return true;
}),
},
{
key: ['turn', 't'],
description: 'Turns yourself around',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ShardConnector } from '../../networking/shardConnector';
import type { ChatRoom, IChatRoomMessageSender } from '../gameContext/chatRoomContextProvider';
import type { IChatInputHandler } from './chatInput';
import { COMMANDS } from './commands';
import type { useNavigate } from 'react-router';

export const COMMAND_KEY = '/';

Expand All @@ -13,6 +14,7 @@ export interface ICommandExecutionContextClient extends ICommandExecutionContext
chatRoom: ChatRoom;
messageSender: IChatRoomMessageSender;
inputHandlerContext: IChatInputHandler;
navigate: ReturnType<typeof useNavigate>;
}

export type IClientCommand = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { useChatInput } from '../chatInput';
import { toast } from 'react-toastify';
import { TOAST_OPTIONS_ERROR } from '../../../persistentToast';
import { RelationshipChangeHandleResult, useRelationship } from '../../releationships/relationshipsContext';
import { useConfirmDialog } from '../../dialog/dialog';
import { useAsyncEvent } from '../../../common/useEvent';
import { useGoToDM } from '../../releationships/relationships';

type MenuType = 'main' | 'admin' | 'relationship';

Expand Down Expand Up @@ -102,11 +105,17 @@ function AdminActionContextMenu(): ReactElement | null {
function BlockMenu({ action, text }: { action: 'add' | 'remove'; text: ReactNode; }): ReactElement {
const directory = useDirectoryConnector();
const { character } = useCharacterMenuContext();
const confirm = useConfirmDialog();

const block = useCallback(() => {
if (confirm(`Are you sure you want to ${action} the account behind ${character.data.name} from your block list?`))
directory.sendMessage('blockList', { action, id: character.data.accountId });
}, [action, character, directory]);
confirm(`Are you sure you want to ${action} the account behind ${character.data.name} from your block list?`)
.then((result) => {
if (result) {
directory.sendMessage('blockList', { action, id: character.data.accountId });
}
})
.catch(() => { /** ignore */ });
}, [action, character.data.accountId, character.data.name, confirm, directory]);

return (
<button onClick={ block } >
Expand All @@ -115,17 +124,19 @@ function BlockMenu({ action, text }: { action: 'add' | 'remove'; text: ReactNode
);
}

const errorHandler = (err: unknown) => toast(err instanceof Error ? err.message : 'An unknown error occurred', TOAST_OPTIONS_ERROR);

function FriendRequestMenu({ action, text }: { action: 'initiate' | 'accept' | 'decline' | 'cancel'; text: ReactNode; }): ReactElement {
const directory = useDirectoryConnector();
const { character } = useCharacterMenuContext();
const confirm = useConfirmDialog();

const request = useCallback(() => {
if (confirm(`Are you sure you want to ${action} adding the account behind ${character.data.name} to your contacts list?`)) {
directory.awaitResponse('friendRequest', { action, id: character.data.accountId })
.then(({ result }) => RelationshipChangeHandleResult(result))
.catch((err) => toast(err instanceof Error ? err.message : 'An unknown error occurred', TOAST_OPTIONS_ERROR));
const [request] = useAsyncEvent(async () => {
if (await confirm(`Are you sure you want to ${action} adding the account behind ${character.data.name} to your contacts list?`)) {
return directory.awaitResponse('friendRequest', { action, id: character.data.accountId });
}
}, [action, character, directory]);
return undefined;
}, RelationshipChangeHandleResult, { errorHandler });

return (
<button onClick={ request } >
Expand All @@ -137,14 +148,14 @@ function FriendRequestMenu({ action, text }: { action: 'initiate' | 'accept' | '
function UnfriendRequestMenu(): ReactElement {
const directory = useDirectoryConnector();
const { character } = useCharacterMenuContext();
const confirm = useConfirmDialog();

const request = useCallback(() => {
if (confirm(`Are you sure you want to remove the account behind ${character.data.name} from your contacts list?`)) {
directory.awaitResponse('unfriend', { id: character.data.accountId })
.then(({ result }) => RelationshipChangeHandleResult(result))
.catch((err) => toast(err instanceof Error ? err.message : 'An unknown error occurred', TOAST_OPTIONS_ERROR));
const [request] = useAsyncEvent(async () => {
if (await confirm(`Are you sure you want to remove the account behind ${character.data.name} from your contacts list?`)) {
return directory.awaitResponse('unfriend', { id: character.data.accountId });
}
}, [character, directory]);
return undefined;
}, RelationshipChangeHandleResult, { errorHandler });

return (
<button onClick={ request } >
Expand All @@ -153,6 +164,19 @@ function UnfriendRequestMenu(): ReactElement {
);
}

function NavigateToDMMenu(): ReactElement | null {
const { currentAccount, character } = useCharacterMenuContext();
const onClick = useGoToDM(character.data.accountId);
if (character.data.accountId === currentAccount?.id)
return null;

return (
<button onClick={ onClick } >
Go to Direct Messages
</button>
);
}

function RelationshipActionContextMenuInner(): ReactElement | null {
const { character } = useCharacterMenuContext();
const rel = useRelationship(character.data.accountId);
Expand Down Expand Up @@ -278,6 +302,7 @@ export function CharacterContextMenu({ character, position, onClose, closeText =
Whisper
</button>
) }
<NavigateToDMMenu />
</>
) }
<AdminActionContextMenu />
Expand Down
28 changes: 26 additions & 2 deletions pandora-client-web/src/components/common/tabs/tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import classNames from 'classnames';
import React, { ReactElement, useMemo, useState, ReactNode } from 'react';
import React, { ReactElement, useMemo, useState, ReactNode, useEffect } from 'react';
import { ChildrenProps } from '../../../common/reactTypes';
import './tabs.scss';
import { Column } from '../container/container';
import { useMatch, useNavigate } from 'react-router';
import { useEvent } from '../../../common/useEvent';

interface TabProps extends ChildrenProps {
name: ReactNode;
Expand All @@ -17,23 +19,45 @@ export function TabContainer({
className,
collapsable,
tabsPosition = 'top',
urlMatch,
}: {
children: (ReactElement<TabProps> | undefined | null)[];
id?: string;
className?: string;
collapsable?: true;
urlMatch?: `${string}/:tab`;
/**
* Where are the tabs positioned, relative to the content
* @default 'top'
*/
tabsPosition?: 'top' | 'left';
}): ReactElement {
// eslint-disable-next-line react-hooks/rules-of-hooks
const match = urlMatch ? useMatch(urlMatch)?.params : null;

const [currentTab, setTab] = useState(() => {
const defaultTab = children.findIndex((c) => c && c.props.default);
return defaultTab >= 0 ? defaultTab : children.findIndex((c) => !!c);
});

const navigate = useNavigate();
const setTabAction = useEvent((name: unknown, index: number) => {
setTab(index);
if (typeof name === 'string' && urlMatch) {
navigate(urlMatch.replace(':tab', name));
}
});

useEffect(() => {
const tab = match && 'tab' in match ? match.tab : null;
if (tab) {
const index = children.findIndex((c) => c?.props.name === tab);
if (index >= 0) {
setTab(index);
}
}
}, [match, children]);

const [collapsed, setCollapsed] = useState(false);

const tabs = useMemo<(TabProps | undefined)[]>(() => children.map((c) => c?.props), [children]);
Expand All @@ -45,7 +69,7 @@ export function TabContainer({
tabs.map((tab, index) => (tab &&
<button key={ index }
className={ classNames('tab', { active: index === currentTab }, tab.tabClassName) }
onClick={ tab.onClick ?? (() => setTab(index)) }
onClick={ tab.onClick ?? (() => setTabAction(tab.name, index)) }
>
{ tab.name }
</button>
Expand Down
99 changes: 98 additions & 1 deletion pandora-client-web/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { createHtmlPortalNode, HtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import React, { createContext, useContext, ReactElement, PureComponent, ReactNode } from 'react';
import React, { createContext, useContext, ReactElement, PureComponent, ReactNode, useCallback, useRef, useEffect } from 'react';
import { Rnd } from 'react-rnd';
import { noop, sortBy } from 'lodash';
import { ChildrenProps } from '../../common/reactTypes';
import { Button, ButtonProps } from '../common/button/button';
import { Observable, useObservable } from '../../observable';
import './dialog.scss';
import { useEvent } from '../../common/useEvent';
import { DivContainer } from '../common/container/container';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type HtmlPortalNodeAny = HtmlPortalNode<any>;

const DEFAULT_CONFIRM_DIALOG_SYMBOL = Symbol('DEFAULT_CONFIRM_DIALOG_SYMBOL');

const PORTALS = new Observable<readonly ({
priority: number;
node: HtmlPortalNodeAny;
Expand All @@ -23,6 +27,7 @@ export function Dialogs(): ReactElement {
{ portals.map(({ node }, index) => (
<OutPortal key={ index } node={ node } />
)) }
<ConfirmDialog symbol={ DEFAULT_CONFIRM_DIALOG_SYMBOL } />
</>
);
}
Expand Down Expand Up @@ -198,3 +203,95 @@ export function DialogCloseButton({ children, ...props }: ButtonProps): ReactEle
</Button>
);
}

type ConfirmDialogEntry = Readonly<{
title: string;
handler: (result: boolean) => void;
}>;

const CONFIRM_DIALOGS = new Map<symbol, Observable<ConfirmDialogEntry | null>>();

function GetConfirmDialogEntry(symbol: symbol) {
let entry = CONFIRM_DIALOGS.get(symbol);
if (!entry) {
CONFIRM_DIALOGS.set(symbol, entry = new Observable<ConfirmDialogEntry | null>(null));
}
return entry;
}

function useConfirmDialogController(symbol: symbol): {
open: boolean;
title: string;
onConfirm: () => void;
onCancel: () => void;
} {
const observed = useObservable(GetConfirmDialogEntry(symbol));
const onConfirm = useEvent(() => {
if (observed == null)
return;

observed.handler(true);
});
const onCancel = useEvent(() => {
if (observed == null)
return;

observed.handler(false);
});
const open = observed != null;
const title = observed != null ? observed.title : '';
return {
open,
title,
onConfirm,
onCancel,
};
}

type ConfirmDialogProps = {
symbol: symbol;
yes?: ReactNode;
no?: ReactNode;
};

export function ConfirmDialog({ symbol, yes = 'Ok', no = 'Cancel' }: ConfirmDialogProps) {
const { open, title, onConfirm, onCancel } = useConfirmDialogController(symbol);

if (!open)
return null;

return (
<ModalDialog>
<h1>{ title }</h1>
<DivContainer gap='small' justify='end'>
<Button onClick={ onCancel }>
{ no }
</Button>
<Button onClick={ onConfirm }>
{ yes }
</Button>
</DivContainer>
</ModalDialog>
);
}

export function useConfirmDialog(symbol: symbol = DEFAULT_CONFIRM_DIALOG_SYMBOL): (title: string) => Promise<boolean> {
const unset = useRef(false);
const entry = GetConfirmDialogEntry(symbol);
useEffect(() => () => {
if (unset.current) {
entry.value = null;
}
}, [entry]);
return useCallback((title: string) => new Promise<boolean>((resolve) => {
unset.current = true;
entry.value = {
title,
handler: (result) => {
unset.current = false;
entry.value = null;
resolve(result);
},
};
}), [entry]);
}
Loading