Merge pull request #4415 from urbit/lf/tutorial-fixes

Tutorial: bugfixes
This commit is contained in:
matildepark 2021-02-11 13:02:30 -05:00 committed by GitHub
commit 16d83d97ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 357 additions and 139 deletions

View File

@ -122,9 +122,9 @@ module.exports = {
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env.TUTORIAL_HOST': JSON.stringify('~hastuc-dibtux'), 'process.env.TUTORIAL_HOST': JSON.stringify('~hastuc-dibtux'),
'process.env.TUTORIAL_GROUP': JSON.stringify('beginner-island'), 'process.env.TUTORIAL_GROUP': JSON.stringify('beginner-island'),
'process.env.TUTORIAL_CHAT': JSON.stringify('chat-8401'), 'process.env.TUTORIAL_CHAT': JSON.stringify('chat-1704'),
'process.env.TUTORIAL_BOOK': JSON.stringify('notebook-9148'), 'process.env.TUTORIAL_BOOK': JSON.stringify('book-9695'),
'process.env.TUTORIAL_LINKS': JSON.stringify('link-4353'), 'process.env.TUTORIAL_LINKS': JSON.stringify('link-2827'),
}) })
// new CleanWebpackPlugin(), // new CleanWebpackPlugin(),

View File

@ -32,7 +32,7 @@ export default class SettingsApi extends BaseApi<StoreState> {
} }
putEntry(buc: Key, key: Key, val: Value) { putEntry(buc: Key, key: Key, val: Value) {
this.storeAction({ return this.storeAction({
"put-entry": { "put-entry": {
"bucket-key": buc, "bucket-key": buc,
"entry-key": key, "entry-key": key,

View File

@ -39,10 +39,11 @@ const commandIndex = function (currentGroup, groups, associations) {
: !currentGroup; // home workspace or hasn't loaded : !currentGroup; // home workspace or hasn't loaded
const workspace = currentGroup || '/home'; const workspace = currentGroup || '/home';
commands.push(result(`Groups: Create`, `/~landscape/new`, 'Groups', null)); commands.push(result(`Groups: Create`, `/~landscape/new`, 'Groups', null));
commands.push(result(`Groups: Join`, `/~landscape/join`, 'Groups', null));
if (canAdd) { if (canAdd) {
commands.push(result(`Channel: Create`, `/~landscape${workspace}/new`, 'Groups', null)); commands.push(result(`Channel: Create`, `/~landscape${workspace}/new`, 'Groups', null));
} }
commands.push(result(`Groups: Join`, `/~landscape/join`, 'Groups', null));
commands.push(result(`Tutorial`, '/?tutorial=true', 'Null', null));
return commands; return commands;
}; };

View File

@ -1,8 +1,9 @@
import { TutorialProgress, Associations } from "~/types"; import { TutorialProgress, Associations } from "~/types";
import { AlignX, AlignY } from "~/logic/lib/relativePosition"; import { AlignX, AlignY } from "~/logic/lib/relativePosition";
import { Direction } from "~/views/components/Triangle";
export const MODAL_WIDTH = 256; export const MODAL_WIDTH = 256;
export const MODAL_HEIGHT = 180; export const MODAL_HEIGHT = 256;
export const MODAL_WIDTH_PX = `${MODAL_WIDTH}px`; export const MODAL_WIDTH_PX = `${MODAL_WIDTH}px`;
export const MODAL_HEIGHT_PX = `${MODAL_HEIGHT}px`; export const MODAL_HEIGHT_PX = `${MODAL_HEIGHT}px`;
@ -20,6 +21,7 @@ interface StepDetail {
alignY: AlignY | AlignY[]; alignY: AlignY | AlignY[];
offsetX: number; offsetX: number;
offsetY: number; offsetY: number;
arrow: Direction;
} }
export function hasTutorialGroup(props: { associations: Associations }) { export function hasTutorialGroup(props: { associations: Associations }) {
@ -28,8 +30,36 @@ export function hasTutorialGroup(props: { associations: Associations }) {
); );
} }
export const getTrianglePosition = (dir: Direction) => {
const midY = `${MODAL_HEIGHT / 2 - 8}px`;
const midX = `${MODAL_WIDTH / 2 - 8}px`;
switch(dir) {
case 'East':
return {
top: midY,
right: '-32px'
};
case 'West':
return {
top: midY,
left: '-32px'
}
case 'North':
return {
top: '-32px',
left: midX
};
case 'South':
return {
bottom: '-32px',
left: midX
};
}
}
export const progressDetails: Record<TutorialProgress, StepDetail> = { export const progressDetails: Record<TutorialProgress, StepDetail> = {
hidden: {} as any, hidden: {} as any,
exit: {} as any,
done: { done: {
title: "End", title: "End",
description: description:
@ -41,14 +71,15 @@ export const progressDetails: Record<TutorialProgress, StepDetail> = {
offsetY: 0, offsetY: 0,
}, },
start: { start: {
title: "New group added", title: "New Group added",
description: description:
"We just added you to the Beginner island group to show you around. This group is public, but other groups can be private", "We just added you to the Beginner island group to show you around. This group is public, but other groups can be private",
url: "/", url: "/",
alignX: "right", alignX: "right",
alignY: "top", alignY: "top",
offsetX: MODAL_WIDTH + 8, arrow: "West",
offsetY: 0, offsetX: MODAL_WIDTH + 24,
offsetY: 64,
}, },
"group-desc": { "group-desc": {
title: "What's a group", title: "What's a group",
@ -57,7 +88,8 @@ export const progressDetails: Record<TutorialProgress, StepDetail> = {
url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}`, url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}`,
alignX: "left", alignX: "left",
alignY: "top", alignY: "top",
offsetX: MODAL_WIDTH + 8, arrow: "East",
offsetX: MODAL_WIDTH + 24,
offsetY: MODAL_HEIGHT / 2 - 8, offsetY: MODAL_HEIGHT / 2 - 8,
}, },
channels: { channels: {
@ -67,7 +99,8 @@ export const progressDetails: Record<TutorialProgress, StepDetail> = {
url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}`, url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}`,
alignY: "top", alignY: "top",
alignX: "right", alignX: "right",
offsetX: MODAL_WIDTH + 8, arrow: "West",
offsetX: MODAL_WIDTH + 24,
offsetY: -8, offsetY: -8,
}, },
chat: { chat: {
@ -76,9 +109,10 @@ export const progressDetails: Record<TutorialProgress, StepDetail> = {
"Chat channels are for messaging within your group. Direct Messages are also supported, and are accessible from the “DMs” tile on the homescreen", "Chat channels are for messaging within your group. Direct Messages are also supported, and are accessible from the “DMs” tile on the homescreen",
url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}/resource/chat/ship/${TUTORIAL_HOST}/${TUTORIAL_CHAT}`, url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}/resource/chat/ship/${TUTORIAL_HOST}/${TUTORIAL_CHAT}`,
alignY: "top", alignY: "top",
arrow: "North",
alignX: "right", alignX: "right",
offsetX: 0, offsetY: -56,
offsetY: -32, offsetX: -8,
}, },
link: { link: {
title: "Collection", title: "Collection",
@ -87,8 +121,9 @@ export const progressDetails: Record<TutorialProgress, StepDetail> = {
url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}/resource/link/ship/${TUTORIAL_HOST}/${TUTORIAL_LINKS}`, url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}/resource/link/ship/${TUTORIAL_HOST}/${TUTORIAL_LINKS}`,
alignY: "top", alignY: "top",
alignX: "right", alignX: "right",
offsetX: 0, arrow: "North",
offsetY: -32, offsetX: -8,
offsetY: -56,
}, },
publish: { publish: {
title: "Notebook", title: "Notebook",
@ -97,18 +132,19 @@ export const progressDetails: Record<TutorialProgress, StepDetail> = {
url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}/resource/publish/ship/${TUTORIAL_HOST}/${TUTORIAL_BOOK}`, url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}/resource/publish/ship/${TUTORIAL_HOST}/${TUTORIAL_BOOK}`,
alignY: "top", alignY: "top",
alignX: "right", alignX: "right",
offsetX: 0, arrow: "North",
offsetY: -32, offsetX: -8,
offsetY: -56,
}, },
notifications: { notifications: {
title: "Notifications", title: "Notifications",
description: description: "You will get updates from subscribed channels and mentions here. You can access Notifications through Leap.",
"Subscribing to a channel will send you notifications when there are new updates. You will also receive a notification when someone mentions your name in a channel.", url: '/~notifications',
url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}/resource/publish/ship/${TUTORIAL_HOST}/${TUTORIAL_BOOK}/settings#notifications`,
alignY: "top", alignY: "top",
alignX: "right", alignX: "left",
offsetX: 0, arrow: "North",
offsetY: -32, offsetX: (MODAL_WIDTH / 2) - 16,
offsetY: -48,
}, },
profile: { profile: {
title: "Profile", title: "Profile",
@ -117,6 +153,7 @@ export const progressDetails: Record<TutorialProgress, StepDetail> = {
url: `/~profile/~${window.ship}`, url: `/~profile/~${window.ship}`,
alignY: "top", alignY: "top",
alignX: "right", alignX: "right",
arrow: "South",
offsetX: -300 + MODAL_WIDTH / 2, offsetX: -300 + MODAL_WIDTH / 2,
offsetY: -120 + MODAL_HEIGHT / 2, offsetY: -120 + MODAL_HEIGHT / 2,
}, },
@ -127,7 +164,8 @@ export const progressDetails: Record<TutorialProgress, StepDetail> = {
url: `/~profile/~${window.ship}`, url: `/~profile/~${window.ship}`,
alignY: "top", alignY: "top",
alignX: "left", alignX: "left",
offsetX: 0, arrow: "North",
offsetY: -32, offsetX: 0.3 *MODAL_HEIGHT,
offsetY: -48,
}, },
}; };

View File

@ -12,6 +12,8 @@ import { Box } from "@tlon/indigo-react";
import { useOutsideClick } from "./useOutsideClick"; import { useOutsideClick } from "./useOutsideClick";
import { ModalOverlay } from "~/views/components/ModalOverlay"; import { ModalOverlay } from "~/views/components/ModalOverlay";
import {Portal} from "~/views/components/Portal"; import {Portal} from "~/views/components/Portal";
import {ModalPortal} from "~/views/components/ModalPortal";
import {PropFunc} from "~/types";
type ModalFunc = (dismiss: () => void) => JSX.Element; type ModalFunc = (dismiss: () => void) => JSX.Element;
interface UseModalProps { interface UseModalProps {
@ -23,7 +25,8 @@ interface UseModalResult {
showModal: () => void; showModal: () => void;
} }
export function useModal(props: UseModalProps): UseModalResult { export function useModal(props: UseModalProps & PropFunc<typeof Box>): UseModalResult {
const { modal, ...rest } = props;
const innerRef = useRef<HTMLElement>(); const innerRef = useRef<HTMLElement>();
const [modalShown, setModalShown] = useState(false); const [modalShown, setModalShown] = useState(false);
@ -39,15 +42,13 @@ export function useModal(props: UseModalProps): UseModalResult {
() => () =>
!modalShown !modalShown
? null ? null
: typeof props.modal === "function" : typeof modal === "function"
? props.modal(dismiss) ? modal(dismiss)
: props.modal, : modal,
[modalShown, props.modal, dismiss] [modalShown, modal, dismiss]
); );
useOutsideClick(innerRef, dismiss); const modalComponent = useMemo(
const modal = useMemo(
() => () =>
!inner ? null : ( !inner ? null : (
<Portal> <Portal>
@ -63,7 +64,8 @@ export function useModal(props: UseModalProps): UseModalResult {
alignItems="stretch" alignItems="stretch"
flexDirection="column" flexDirection="column"
spacing="2" spacing="2"
dismiss={dismiss}
{...rest}
> >
{inner} {inner}
</ModalOverlay> </ModalOverlay>
@ -74,6 +76,6 @@ export function useModal(props: UseModalProps): UseModalResult {
return { return {
showModal, showModal,
modal, modal: modalComponent,
}; };
} }

View File

@ -9,8 +9,7 @@ export function useOutsideClick(
const portalRoot = document.querySelector('#portal-root')!; const portalRoot = document.querySelector('#portal-root')!;
if ( if (
ref.current && ref.current &&
!ref.current.contains(event.target as any) && !ref.current.contains(event.target as any)
(!portalRoot.contains(ref.current) || portalRoot.contains(event.target as any))
) { ) {
onClick(); onClick();
} }

View File

@ -41,7 +41,6 @@ function decodePolicy(policy: Enc<GroupPolicy>): GroupPolicy {
} }
function decodeTags(tags: Enc<Tags>): Tags { function decodeTags(tags: Enc<Tags>): Tags {
console.log(tags);
return _.reduce( return _.reduce(
tags, tags,
(acc, ships, key): Tags => { (acc, ships, key): Tags => {

View File

@ -1,4 +1,4 @@
export const tutorialProgress = ['hidden', 'start', 'group-desc', 'channels', 'chat', 'link', 'publish', 'notifications', 'profile', 'leap', 'done'] as const; export const tutorialProgress = ['hidden', 'start', 'group-desc', 'channels', 'chat', 'link', 'publish', 'profile', 'leap', 'notifications', 'done', 'exit'] as const;
export type TutorialProgress = typeof tutorialProgress[number]; export type TutorialProgress = typeof tutorialProgress[number];
interface LocalUpdateSetDark { interface LocalUpdateSetDark {

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import f from 'lodash/fp'; import f from 'lodash/fp';
@ -20,6 +20,7 @@ import { JoinGroup } from "~/views/landscape/components/JoinGroup";
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import useLocalState from "~/logic/state/local"; import useLocalState from "~/logic/state/local";
import { useWaitForProps } from '~/logic/lib/useWaitForProps'; import { useWaitForProps } from '~/logic/lib/useWaitForProps';
import { useQuery } from "~/logic/lib/useQuery";
import { import {
hasTutorialGroup, hasTutorialGroup,
TUTORIAL_GROUP, TUTORIAL_GROUP,
@ -69,11 +70,25 @@ export default function LaunchApp(props) {
</Box> </Box>
); );
const { query } = useQuery();
useEffect(() => {
if(query.get('tutorial')) {
if(hasTutorialGroup(props)) {
nextTutStep();
} else {
showModal();
}
}
}, [query]);
const { tutorialProgress, nextTutStep } = useLocalState(tutSelector); const { tutorialProgress, nextTutStep } = useLocalState(tutSelector);
const waiter = useWaitForProps(props); const waiter = useWaitForProps(props);
const { modal, showModal } = useModal({ const { modal, showModal } = useModal({
position: 'relative',
maxWidth: '350px',
modal: (dismiss) => { modal: (dismiss) => {
const onDismiss = (e) => { const onDismiss = (e) => {
e.stopPropagation(); e.stopPropagation();
@ -87,7 +102,7 @@ export default function LaunchApp(props) {
await waiter(hasTutorialGroup); await waiter(hasTutorialGroup);
await Promise.all( await Promise.all(
[TUTORIAL_BOOK, TUTORIAL_CHAT, TUTORIAL_LINKS].map(graph => [TUTORIAL_BOOK, TUTORIAL_CHAT, TUTORIAL_LINKS].map(graph =>
api.graph.join(TUTORIAL_HOST, graph))); props.api.graph.join(TUTORIAL_HOST, graph)));
await waiter(p => { await waiter(p => {
return `/ship/${TUTORIAL_HOST}/${TUTORIAL_CHAT}` in p.associations.graph && return `/ship/${TUTORIAL_HOST}/${TUTORIAL_CHAT}` in p.associations.graph &&
@ -99,7 +114,10 @@ export default function LaunchApp(props) {
dismiss(); dismiss();
} }
return ( return (
<Col gapY="2" p="3"> <Col maxWidth="350px" gapY="2" p="3">
<Box position="absolute" left="-16px" top="-16px">
<Icon width="32px" height="32px" color="blue" display="block" icon="LargeBullet" />
</Box>
<Text lineHeight="tall" fontWeight="medium">Welcome</Text> <Text lineHeight="tall" fontWeight="medium">Welcome</Text>
<Text lineHeight="tall"> <Text lineHeight="tall">
You have been invited to use Landscape, an interface to chat You have been invited to use Landscape, an interface to chat
@ -108,7 +126,7 @@ export default function LaunchApp(props) {
Would you like a tour of Landscape? Would you like a tour of Landscape?
</Text> </Text>
<Row gapX="2" justifyContent="flex-end"> <Row gapX="2" justifyContent="flex-end">
<Button onClick={onDismiss}>Skip</Button> <Button backgroundColor="washedGray" onClick={onDismiss}>Skip</Button>
<StatelessAsyncButton primary onClick={onContinue}> <StatelessAsyncButton primary onClick={onContinue}>
Yes Yes
</StatelessAsyncButton> </StatelessAsyncButton>
@ -116,12 +134,14 @@ export default function LaunchApp(props) {
</Col> </Col>
)} )}
}); });
const hasLoaded = useMemo(() => Object.keys(props.contacts).length > 0, [props.contacts]);
useEffect(() => { useEffect(() => {
const seenTutorial = _.get(props.settings, ['tutorial', 'seen'], true); const seenTutorial = _.get(props.settings, ['tutorial', 'seen'], true);
if(!seenTutorial && tutorialProgress === 'hidden') { if(hasLoaded && !seenTutorial && tutorialProgress === 'hidden') {
showModal(); showModal();
} }
}, [props.settings]); }, [props.settings, hasLoaded]);
return ( return (
<> <>

View File

@ -1,4 +1,4 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useState, useRef } from "react";
import _ from 'lodash'; import _ from 'lodash';
import { Box, Col, Text, Row } from "@tlon/indigo-react"; import { Box, Col, Text, Row } from "@tlon/indigo-react";
import { Link, Switch, Route } from "react-router-dom"; import { Link, Switch, Route } from "react-router-dom";
@ -12,11 +12,13 @@ import { Dropdown } from "~/views/components/Dropdown";
import { Formik } from "formik"; import { Formik } from "formik";
import { FormikOnBlur } from "~/views/components/FormikOnBlur"; import { FormikOnBlur } from "~/views/components/FormikOnBlur";
import GroupSearch from "~/views/components/GroupSearch"; import GroupSearch from "~/views/components/GroupSearch";
import {useTutorialModal} from "~/views/components/useTutorialModal";
const baseUrl = "/~notifications"; const baseUrl = "/~notifications";
const HeaderLink = ( const HeaderLink = React.forwardRef((
props: PropFunc<typeof Text> & { view?: string; current: string } props: PropFunc<typeof Text> & { view?: string; current: string },
ref
) => { ) => {
const { current, view, ...textProps } = props; const { current, view, ...textProps } = props;
const to = view ? `${baseUrl}/${view}` : baseUrl; const to = view ? `${baseUrl}/${view}` : baseUrl;
@ -24,10 +26,10 @@ const HeaderLink = (
return ( return (
<Link to={to}> <Link to={to}>
<Text px="2" {...textProps} gray={!active} /> <Text ref={ref} px="2" {...textProps} gray={!active} />
</Link> </Link>
); );
}; });
interface NotificationFilter { interface NotificationFilter {
groups: string[]; groups: string[];
@ -37,8 +39,8 @@ export default function NotificationsScreen(props: any) {
const relativePath = (p: string) => baseUrl + p; const relativePath = (p: string) => baseUrl + p;
const [filter, setFilter] = useState<NotificationFilter>({ groups: [] }); const [filter, setFilter] = useState<NotificationFilter>({ groups: [] });
const onSubmit = async (values: { groups: string }) => { const onSubmit = async ({ groups } : NotificationFilter) => {
setFilter({ groups: values.groups ? [values.groups] : [] }); setFilter({ groups });
}; };
const onReadAll = useCallback(() => { const onReadAll = useCallback(() => {
props.api.hark.readAll() props.api.hark.readAll()
@ -49,6 +51,8 @@ export default function NotificationsScreen(props: any) {
: filter.groups : filter.groups
.map((g) => props.associations?.groups?.[g]?.metadata?.title) .map((g) => props.associations?.groups?.[g]?.metadata?.title)
.join(", "); .join(", ");
const anchorRef = useRef<HTMLElement | null>(null);
useTutorialModal('notifications', true, anchorRef.current);
return ( return (
<Switch> <Switch>
<Route <Route
@ -74,7 +78,7 @@ export default function NotificationsScreen(props: any) {
<Text>Updates</Text> <Text>Updates</Text>
<Row> <Row>
<Box> <Box>
<HeaderLink current={view} view=""> <HeaderLink ref={anchorRef} current={view} view="">
Inbox Inbox
</HeaderLink> </HeaderLink>
</Box> </Box>

View File

@ -54,13 +54,14 @@ export function Profile(props: any) {
height="100%" height="100%"
width="100%"> width="100%">
<Box <Box
ref={anchorRef}
maxWidth="600px" maxWidth="600px"
width="100%"> width="100%">
{ ship === `~${window.ship}` ? ( { ship === `~${window.ship}` ? (
<SetStatus ship={ship} contact={contact} api={props.api} /> <SetStatus ship={ship} contact={contact} api={props.api} />
) : null ) : null
} }
<Row ref={anchorRef} width="100%" height="300px"> <Row width="100%" height="300px">
{cover} {cover}
</Row> </Row>
<Row <Row

View File

@ -75,7 +75,8 @@ export function GroupSearch<I extends string, V extends FormValues<I>>(props: Gr
touched: touchedFields, touched: touchedFields,
errors, errors,
initialValues, initialValues,
setFieldValue setFieldValue,
setFieldTouched,
} = useFormikContext<V>(); } = useFormikContext<V>();
const [inputIdx, setInputIdx] = useState(initialValues[id].length); const [inputIdx, setInputIdx] = useState(initialValues[id].length);
const name = `${id}[${inputIdx}]`; const name = `${id}[${inputIdx}]`;
@ -118,10 +119,12 @@ export function GroupSearch<I extends string, V extends FormValues<I>>(props: Gr
render={(arrayHelpers) => { render={(arrayHelpers) => {
const onSelect = (a: Association) => { const onSelect = (a: Association) => {
setFieldValue(name, a.group); setFieldValue(name, a.group);
setFieldTouched(name, true, false);
setInputIdx(s => s+1); setInputIdx(s => s+1);
}; };
const onRemove = (idx: number) => { const onRemove = (idx: number) => {
setFieldTouched(name, true, false);
setInputIdx(s => s - 1); setInputIdx(s => s - 1);
arrayHelpers.remove(idx); arrayHelpers.remove(idx);
}; };
@ -145,6 +148,7 @@ export function GroupSearch<I extends string, V extends FormValues<I>>(props: Gr
} }
getKey={(a: Association) => a.group} getKey={(a: Association) => a.group}
onSelect={onSelect} onSelect={onSelect}
onBlur={() => {}}
/> />
{value?.length > 0 && ( {value?.length > 0 && (
value.map((e, idx: number) => { value.map((e, idx: number) => {

View File

@ -1,13 +1,35 @@
import React from "react"; import React, { useCallback, UIEvent, MouseEvent, useRef } from "react";
import { Box } from "@tlon/indigo-react"; import { Box } from "@tlon/indigo-react";
import { PropFunc } from "~/types/util"; import { PropFunc } from "~/types/util";
interface ModalOverlayProps { interface ModalOverlayProps {
spacing: PropFunc<typeof Box>["m"]; spacing: PropFunc<typeof Box>["m"];
dismiss: () => void;
} }
export const ModalOverlay = React.forwardRef( type Props = ModalOverlayProps & PropFunc<typeof Box>;
(props: ModalOverlayProps & PropFunc<typeof Box>, ref) => { export const ModalOverlay = (props: Props) => {
const { spacing, ...rest } = props; const { spacing, ...rest } = props;
const ref = useRef<HTMLElement | null>(null);
const onClick = useCallback(
(e: any) => {
if (!(ref as any).current.contains(e.target)) {
props.dismiss();
}
e.stopPropagation();
},
[props.dismiss, ref]
);
const onKeyDown = useCallback(
(e: any) => {
if (e.key === "Escape") {
props.dismiss();
e.stopPropagation();
}
},
[props.dismiss, ref]
);
return ( return (
<Box <Box
backgroundColor="scales.black20" backgroundColor="scales.black20"
@ -15,15 +37,16 @@ export const ModalOverlay = React.forwardRef(
top="0px" top="0px"
width="100%" width="100%"
height="100%" height="100%"
zIndex={10}
position="fixed" position="fixed"
display="flex" display="flex"
zIndex={10}
justifyContent="center" justifyContent="center"
alignItems="center" alignItems="center"
p={spacing} p={spacing}
onClick={onClick}
onKeyDown={onKeyDown}
> >
<Box ref={ref} {...rest} /> <Box ref={ref} {...rest} />
</Box> </Box>
); );
} }
);

View File

@ -47,7 +47,9 @@ const StatusBar = (props) => {
const anchorRef = useRef(null); const anchorRef = useRef(null);
useTutorialModal('leap', true, anchorRef.current); const leapHighlight = useTutorialModal('leap', true, anchorRef.current);
const floatLeap = leapHighlight && window.matchMedia('(max-width: 550px)').matches;
return ( return (
<Box <Box
@ -63,7 +65,7 @@ const StatusBar = (props) => {
<Button width="32px" borderColor='washedGray' mr='2' px='2' onClick={() => props.history.push('/')} {...props}> <Button width="32px" borderColor='washedGray' mr='2' px='2' onClick={() => props.history.push('/')} {...props}>
<Icon icon='Spaces' color='black'/> <Icon icon='Spaces' color='black'/>
</Button> </Button>
<StatusBarItem mr={2} onClick={() => toggleOmnibox()}> <StatusBarItem float={floatLeap} mr={2} onClick={() => toggleOmnibox()}>
{ !props.doNotDisturb && (props.notificationsCount > 0 || invites.length > 0) && { !props.doNotDisturb && (props.notificationsCount > 0 || invites.length > 0) &&
(<Box display="block" right="-8px" top="-8px" position="absolute" > (<Box display="block" right="-8px" top="-8px" position="absolute" >
<Icon color="blue" icon="Bullet" /> <Icon color="blue" icon="Bullet" />

View File

@ -6,21 +6,25 @@ const Row = styled(_Row)`
cursor: pointer; cursor: pointer;
`; `;
type StatusBarItemProps = Parameters<typeof Row>[0] & { badge?: boolean }; type StatusBarItemProps = Parameters<typeof Row>[0] & { badge?: boolean; float?: boolean; };
export function StatusBarItem({ export function StatusBarItem({
badge, badge,
children, children,
float,
...props ...props
}: StatusBarItemProps) { }: StatusBarItemProps) {
const floatPos = float ? { zIndex: 10, boxShadow: 'rgba(0,0,0,0.2) 0px 0px 0px 999px' } : {};
return ( return (
<Button <Button
style={{ position: 'relative' }} style={{ position: 'relative', ...floatPos }}
border={1} border={1}
color="washedGray" color="washedGray"
bg="white" bg="white"
px={2} px={2}
overflow='visible' overflow='visible'
zIndex={10}
boxShadow="1px 1px black"
{...props} {...props}
> >
{children} {children}

View File

@ -0,0 +1,46 @@
import React from "react";
import _ from "lodash";
import { Box } from "@tlon/indigo-react";
import { PropFunc } from "~/types";
export type Direction = "East" | "South" | "West" | "North";
type TriangleProps = PropFunc<typeof Box> & {
direction: Direction;
color: string;
size: number;
};
const borders = ["Top", "Bottom", "Left", "Right"] as const;
const directionToBorder = (dir: Direction): typeof borders[number] => {
switch (dir) {
case "East":
return "Left";
case "West":
return "Right";
case "North":
return "Bottom";
case "South":
return "Top";
}
};
const getBorders = (dir: Direction, height: number, color: string) => {
const solidBorder = directionToBorder(dir);
const transparent = borders.filter((x) => x !== solidBorder);
return {
[`border${solidBorder}`]: `${height}px solid`,
[`border${solidBorder}Color`]: color,
..._.mapValues(
_.keyBy(transparent, (border) => `border${border}`),
() => "16px solid transparent"
),
};
};
export function Triangle({ direction, color, size, ...rest }: TriangleProps) {
const borders = getBorders(direction, size, color);
return <Box width="0px" height="0px" {...borders} {...rest} />;
}

View File

@ -3,7 +3,6 @@ import { Col, Text, BaseLabel, Label } from "@tlon/indigo-react";
import GlobalApi from "~/logic/api/global"; import GlobalApi from "~/logic/api/global";
import { Association, NotificationGraphConfig } from "~/types"; import { Association, NotificationGraphConfig } from "~/types";
import { StatelessAsyncToggle } from "~/views/components/StatelessAsyncToggle"; import { StatelessAsyncToggle } from "~/views/components/StatelessAsyncToggle";
import {useTutorialModal} from "~/views/components/useTutorialModal";
interface ChannelNotificationsProps { interface ChannelNotificationsProps {
api: GlobalApi; api: GlobalApi;
@ -27,7 +26,6 @@ export function ChannelNotifications(props: ChannelNotificationsProps) {
const anchorRef = useRef<HTMLElement | null>(null) const anchorRef = useRef<HTMLElement | null>(null)
useTutorialModal('notifications', true, anchorRef.current);
return ( return (
<Col mb="6" gapY="4" flexShrink={0}> <Col mb="6" gapY="4" flexShrink={0}>

View File

@ -1,4 +1,4 @@
import React, { useRef } from "react"; import React, { useRef, useCallback } from "react";
import { ModalOverlay } from "~/views/components/ModalOverlay"; import { ModalOverlay } from "~/views/components/ModalOverlay";
import { Col, Box, Text, Row } from "@tlon/indigo-react"; import { Col, Box, Text, Row } from "@tlon/indigo-react";
import { ChannelPopoverRoutesSidebar } from "./Sidebar"; import { ChannelPopoverRoutesSidebar } from "./Sidebar";
@ -36,9 +36,9 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
const overlayRef = useRef<HTMLElement>(); const overlayRef = useRef<HTMLElement>();
const history = useHistory(); const history = useHistory();
useOutsideClick(overlayRef, () => { const onDismiss = useCallback(() => {
history.push(props.baseUrl); history.push(props.baseUrl);
}); }, [history, props.baseUrl]);
const handleUnsubscribe = async () => { const handleUnsubscribe = async () => {
const [,,ship,name] = association.resource.split('/'); const [,,ship,name] = association.resource.split('/');
@ -62,6 +62,7 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
width="100%" width="100%"
spacing={[3, 5, 7]} spacing={[3, 5, 7]}
ref={overlayRef} ref={overlayRef}
dismiss={onDismiss}
> >
<Row <Row
flexDirection={["column", "row"]} flexDirection={["column", "row"]}

View File

@ -89,7 +89,6 @@ export function GroupSwitcher(props: {
flexShrink={0} flexShrink={0}
height='48px' height='48px'
backgroundColor="white" backgroundColor="white"
zIndex="2"
position="sticky" position="sticky"
top="0px" top="0px"
pl='3' pl='3'

View File

@ -1,7 +1,6 @@
import React, { useRef, useCallback } from "react"; import React, { useRef, useCallback } from "react";
import { Route, Switch, RouteComponentProps, Link } from "react-router-dom"; import { Route, Switch, RouteComponentProps, Link } from "react-router-dom";
import { Box, Row, Col, Icon, Text } from "@tlon/indigo-react"; import { Box, Row, Col, Icon, Text } from "@tlon/indigo-react";
import { useOutsideClick } from "~/logic/lib/useOutsideClick";
import { HoverBoxLink } from "~/views/components/HoverBox"; import { HoverBoxLink } from "~/views/components/HoverBox";
import { Contacts, Contact } from "~/types/contact-update"; import { Contacts, Contact } from "~/types/contact-update";
import { Group } from "~/types/group-update"; import { Group } from "~/types/group-update";
@ -33,10 +32,9 @@ export function PopoverRoutes(
const relativeUrl = (url: string) => `${props.baseUrl}/popover${url}`; const relativeUrl = (url: string) => `${props.baseUrl}/popover${url}`;
const innerRef = useRef(null); const innerRef = useRef(null);
const onOutsideClick = useCallback(() => { const onDismiss = useCallback(() => {
props.history.push(props.baseUrl); props.history.push(props.baseUrl);
}, [props.history.push, props.baseUrl]); }, [props.history.push, props.baseUrl]);
useOutsideClick(innerRef, onOutsideClick);
useHashLink(); useHashLink();
@ -62,6 +60,7 @@ export function PopoverRoutes(
width="100%" width="100%"
height="100%" height="100%"
bg="white" bg="white"
dismiss={onDismiss}
> >
<Box <Box
display="grid" display="grid"

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import _ from 'lodash';
import { Box, Col, Row, Button, Text, Icon, Action } from "@tlon/indigo-react"; import { Box, Col, Row, Button, Text, Icon, Action } from "@tlon/indigo-react";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { TutorialProgress, tutorialProgress as progress } from "~/types"; import { TutorialProgress, tutorialProgress as progress } from "~/types";
@ -13,10 +14,13 @@ import {
MODAL_HEIGHT, MODAL_HEIGHT,
TUTORIAL_HOST, TUTORIAL_HOST,
TUTORIAL_GROUP, TUTORIAL_GROUP,
getTrianglePosition,
} from "~/logic/lib/tutorialModal"; } from "~/logic/lib/tutorialModal";
import { getRelativePosition } from "~/logic/lib/relativePosition"; import { getRelativePosition } from "~/logic/lib/relativePosition";
import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton"; import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton";
import GlobalApi from "~/logic/api/global"; import GlobalApi from "~/logic/api/global";
import {Triangle} from "~/views/components/Triangle";
import {ModalOverlay} from "~/views/components/ModalOverlay";
const localSelector = selectLocalState([ const localSelector = selectLocalState([
"tutorialProgress", "tutorialProgress",
@ -24,6 +28,7 @@ const localSelector = selectLocalState([
"prevTutStep", "prevTutStep",
"tutorialRef", "tutorialRef",
"hideTutorial", "hideTutorial",
"set"
]); ]);
export function TutorialModal(props: { api: GlobalApi }) { export function TutorialModal(props: { api: GlobalApi }) {
@ -33,10 +38,12 @@ export function TutorialModal(props: { api: GlobalApi }) {
nextTutStep, nextTutStep,
prevTutStep, prevTutStep,
hideTutorial, hideTutorial,
set: setLocalState
} = useLocalState(localSelector); } = useLocalState(localSelector);
const { const {
title, title,
description, description,
arrow,
alignX, alignX,
alignY, alignY,
offsetX, offsetX,
@ -44,23 +51,22 @@ export function TutorialModal(props: { api: GlobalApi }) {
} = progressDetails[tutorialProgress]; } = progressDetails[tutorialProgress];
const [coords, setCoords] = useState({}); const [coords, setCoords] = useState({});
const [paused, setPaused] = useState(false);
const history = useHistory(); const history = useHistory();
const next = useCallback( const next = useCallback( () => {
(e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.stopPropagation();
const idx = progress.findIndex((p) => p === tutorialProgress); const idx = progress.findIndex((p) => p === tutorialProgress);
const { url } = progressDetails[progress[idx + 1]]; const { url } = progressDetails[progress[idx + 1]];
history.push(url);
nextTutStep(); nextTutStep();
history.push(url);
}, },
[nextTutStep, history, tutorialProgress, setCoords] [nextTutStep, history, tutorialProgress, setCoords]
); );
const prev = useCallback(() => { const prev = useCallback(() => {
const idx = progress.findIndex((p) => p === tutorialProgress); const idx = progress.findIndex((p) => p === tutorialProgress);
history.push(progressDetails[progress[idx - 1]].url);
prevTutStep(); prevTutStep();
history.push(progressDetails[progress[idx - 1]].url);
}, [prevTutStep, history, tutorialProgress]); }, [prevTutStep, history, tutorialProgress]);
const updatePos = useCallback(() => { const updatePos = useCallback(() => {
@ -75,25 +81,36 @@ export function TutorialModal(props: { api: GlobalApi }) {
if(key === 'bottom' || key === 'left') { if(key === 'bottom' || key === 'left') {
return ['0px', ...value]; return ['0px', ...value];
} }
return [null, ...value]; return ['unset', ...value];
}); });
if(!('bottom' in withMobile)) { if(!('bottom' in withMobile)) {
withMobile.bottom = ['0px', null]; withMobile.bottom = ['0px', 'unset'];
} }
if(!('left' in withMobile)) { if(!('left' in withMobile)) {
withMobile.left = ['0px', null]; withMobile.left = ['0px', 'unset'];
} }
if (newCoords) { if (newCoords) {
setCoords(withMobile); setCoords(withMobile);
} else {
setCoords({});
} }
}, [tutorialRef]); }, [tutorialRef]);
const dismiss = useCallback(() => { const dismiss = useCallback(async () => {
hideTutorial(); hideTutorial();
props.api.settings.putEntry("tutorial", "seen", true); await props.api.settings.putEntry('tutorial', 'seen', true);
}, [hideTutorial, props.api]); }, [hideTutorial, props.api]);
const bailExit = useCallback(() => {
setPaused(false);
}, []);
const tryExit = useCallback(() => {
setPaused(true);
}, []);
const leaveGroup = useCallback(async () => { const leaveGroup = useCallback(async () => {
await props.api.groups.leaveGroup(TUTORIAL_HOST, TUTORIAL_GROUP); await props.api.groups.leaveGroup(TUTORIAL_HOST, TUTORIAL_GROUP);
}, [props.api]); }, [props.api]);
@ -108,27 +125,81 @@ export function TutorialModal(props: { api: GlobalApi }) {
) { ) {
const interval = setInterval(updatePos, 100); const interval = setInterval(updatePos, 100);
return () => { return () => {
setCoords({});
clearInterval(interval); clearInterval(interval);
}; };
} }
return () => {}; return () => {};
}, [tutorialRef, tutorialProgress, updatePos]); }, [tutorialRef, tutorialProgress, updatePos]);
// manually center final window const triPos = getTrianglePosition(arrow);
useEffect(() => {
if (tutorialProgress === "done") { if (tutorialProgress === 'done') {
const { innerWidth, innerHeight } = window; return (
const left = ["0px", `${(innerWidth - MODAL_WIDTH) / 2}px`]; <Portal>
const top = [null, `${(innerHeight - MODAL_HEIGHT) / 2}px`]; <ModalOverlay dismiss={dismiss} borderRadius="2" maxWidth="270px" backgroundColor="white">
const bottom = ["0px", null]; <Col p="2" bg="lightBlue">
setCoords({ top, left, bottom }); <Col mb="1">
<Text lineHeight="tall" fontWeight="bold">
Tutorial Finished
</Text>
<Text fontSize="0" gray>
{progressIdx} of {progress.length - 1}
</Text>
</Col>
<Text lineHeight="tall">
This tutorial is finished. Would you like to leave Beginner Island?
</Text>
<Row mt="2" gapX="2" justifyContent="flex-end">
<Button backgroundColor="washedGray" onClick={dismiss}>
Later
</Button>
<StatelessAsyncButton primary destructive onClick={leaveGroup}>
Leave Group
</StatelessAsyncButton>
</Row>
</Col>
</ModalOverlay>
</Portal>
);
} }
}, [tutorialProgress]);
if (tutorialProgress === "hidden") { if (tutorialProgress === "hidden") {
return null; return null;
} }
if(paused) {
return (
<ModalOverlay dismiss={bailExit} borderRadius="2" maxWidth="270px" backgroundColor="white">
<Col p="2">
<Col mb="1">
<Text lineHeight="tall" fontWeight="bold">
End Tutorial Now?
</Text>
</Col>
<Text lineHeight="tall">
You can always restart the tutorial by typing "tutorial" in Leap.
</Text>
<Row mt="4" gapX="2" justifyContent="flex-end">
<Button backgroundColor="washedGray" onClick={bailExit}>
Cancel
</Button>
<StatelessAsyncButton primary destructive onClick={dismiss}>
End Tutorial
</StatelessAsyncButton>
</Row>
</Col>
</ModalOverlay>
)
}
if(Object.keys(coords).length === 0) {
return null;
}
return ( return (
<Portal> <Portal>
<Box <Box
@ -148,40 +219,47 @@ export function TutorialModal(props: { api: GlobalApi }) {
borderRadius="2" borderRadius="2"
p="2" p="2"
bg="lightBlue" bg="lightBlue"
> >
<Triangle
{...triPos}
position="absolute"
size={16}
color="lightBlue"
direction={arrow}
height="0px"
width="0px"
/>
<Box <Box
right="8px" right="8px"
top="8px" top="8px"
position="absolute" position="absolute"
cursor="pointer" cursor="pointer"
onClick={dismiss} onClick={tryExit}
> >
<Icon icon="X" /> <Icon icon="X" />
</Box> </Box>
<Text lineHeight="tall" fontWeight="medium"> <Col mb="1">
<Text lineHeight="tall" fontWeight="bold">
{title} {title}
</Text> </Text>
<Text lineHeight="tall">{description}</Text> <Text fontSize="0" gray>
{tutorialProgress !== "done" ? ( {progressIdx} of {progress.length - 2}
<Row justifyContent="space-between">
<Action bg="transparent" onClick={prev}>
<Icon icon="ArrowWest" />
</Action>
<Text>
{progressIdx}/{progress.length - 1}
</Text> </Text>
<Action bg="transparent" onClick={next}> </Col>
<Icon icon="ArrowEast" />
</Action> <Text lineHeight="tall">{description}</Text>
</Row> <Row gapX="2" mt="2" justifyContent="flex-end">
) : ( { progressIdx > 1 && (
<Row justifyContent="space-between"> <Button bg="washedGray" onClick={prev}>
<StatelessAsyncButton primary onClick={leaveGroup}> Back
Leave Group </Button>
</StatelessAsyncButton>
<Button onClick={dismiss}>Later</Button>
</Row>
)} )}
<Button primary onClick={next}>
Next
</Button>
</Row>
</Col> </Col>
</Box> </Box>
</Portal> </Portal>