Merge pull request #4388 from urbit/lf/tutorial

interface: add overlay tutorial
This commit is contained in:
Liam Fitzgerald 2021-02-09 13:31:06 +10:00 committed by GitHub
commit 548f2143f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 646 additions and 153 deletions

View File

@ -31,7 +31,10 @@
^- (quip card _this)
=/ old !<(versioned-state old-vase)
?- -.old
%0 [~ this(state old)]
%0
:_ this(state old)
=- [%pass / %agent [our dap]:bowl %poke -]~
settings-event+!>([%put-entry %tutorial %seen b+%|])
==
::
++ on-poke

View File

@ -1,4 +1,5 @@
const path = require('path');
const webpack = require('webpack');
// const HtmlWebpackPlugin = require('html-webpack-plugin');
// const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const urbitrc = require('./urbitrc');
@ -117,7 +118,15 @@ module.exports = {
devtool: 'inline-source-map',
devServer: devServer,
plugins: [
new UrbitShipPlugin(urbitrc)
new UrbitShipPlugin(urbitrc),
new webpack.DefinePlugin({
'process.env.TUTORIAL_HOST': JSON.stringify('~hastuc-dibtux'),
'process.env.TUTORIAL_GROUP': JSON.stringify('beginner-island'),
'process.env.TUTORIAL_CHAT': JSON.stringify('chat-8401'),
'process.env.TUTORIAL_BOOK': JSON.stringify('notebook-9148'),
'process.env.TUTORIAL_LINKS': JSON.stringify('link-4353'),
})
// new CleanWebpackPlugin(),
// new HtmlWebpackPlugin({
// title: 'Hot Module Replacement',

View File

@ -56,8 +56,13 @@ module.exports = {
new CleanWebpackPlugin(),
new webpack.DefinePlugin({
'process.env.LANDSCAPE_STREAM': JSON.stringify(process.env.LANDSCAPE_STREAM),
'process.env.LANDSCAPE_SHORTHASH': JSON.stringify(process.env.LANDSCAPE_SHORTHASH)
})
'process.env.LANDSCAPE_SHORTHASH': JSON.stringify(process.env.LANDSCAPE_SHORTHASH),
'process.env.TUTORIAL_HOST': JSON.stringify('~hastuc-dibtux'),
'process.env.TUTORIAL_GROUP': JSON.stringify('beginner-island'),
'process.env.TUTORIAL_CHAT': JSON.stringify('chat-8401'),
'process.env.TUTORIAL_BOOK': JSON.stringify('notebook-9148'),
'process.env.TUTORIAL_LINKS': JSON.stringify('links-4353'),
}),
// new HtmlWebpackPlugin({
// title: 'Hot Module Replacement',
// template: './public/index.html',

View File

@ -0,0 +1,55 @@
import _ from "lodash";
export const alignY = ["top", "bottom"] as const;
export type AlignY = typeof alignY[number];
export const alignX = ["left", "right"] as const;
export type AlignX = typeof alignX[number];
export function getRelativePosition(
relativeTo: HTMLElement | null,
alignX: AlignX | AlignX[],
alignY: AlignY | AlignY[],
offsetX: number = 0,
offsetY: number = 0
) {
const rect = relativeTo?.getBoundingClientRect();
if (!rect) {
return {};
}
const bounds = {
top: rect.top - offsetY,
left: rect.left - offsetX,
bottom: document.documentElement.clientHeight - rect.bottom - offsetY,
right: document.documentElement.clientWidth - rect.right - offsetX,
};
const alignXArr = _.isArray(alignX) ? alignX : [alignX];
const alignYArr = _.isArray(alignY) ? alignY : [alignY];
return {
..._.reduce(
alignXArr,
(acc, a, idx) => ({
...acc,
[a]: _.zipWith(
[...Array(idx), `${bounds[a]}px`],
acc[a] || [],
(a, b) => a || b || null
),
}),
{}
),
..._.reduce(
alignYArr,
(acc, a, idx) => ({
...acc,
[a]: _.zipWith(
[...Array(idx), `${bounds[a]}px`],
acc[a] || [],
(a, b) => a || b || null
),
}),
{}
),
} as Record<AlignY | AlignX, string[]>;
}

View File

@ -0,0 +1,133 @@
import { TutorialProgress, Associations } from "~/types";
import { AlignX, AlignY } from "~/logic/lib/relativePosition";
export const MODAL_WIDTH = 256;
export const MODAL_HEIGHT = 180;
export const MODAL_WIDTH_PX = `${MODAL_WIDTH}px`;
export const MODAL_HEIGHT_PX = `${MODAL_HEIGHT}px`;
export const TUTORIAL_HOST = process.env.TUTORIAL_HOST!;
export const TUTORIAL_GROUP = process.env.TUTORIAL_GROUP!;
export const TUTORIAL_CHAT = process.env.TUTORIAL_CHAT!;
export const TUTORIAL_BOOK = process.env.TUTORIAL_BOOK!;
export const TUTORIAL_LINKS = process.env.TUTORIAL_LINKS!;
interface StepDetail {
title: string;
description: string;
url: string;
alignX: AlignX | AlignX[];
alignY: AlignY | AlignY[];
offsetX: number;
offsetY: number;
}
export function hasTutorialGroup(props: { associations: Associations }) {
return (
`/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}` in props.associations.groups
);
}
export const progressDetails: Record<TutorialProgress, StepDetail> = {
hidden: {} as any,
done: {
title: "End",
description:
"This tutorial is finished. Would you like to leave Beginner Island?",
url: "/",
alignX: "right",
alignY: "top",
offsetX: MODAL_WIDTH + 8,
offsetY: 0,
},
start: {
title: "New group added",
description:
"We just added you to the Beginner island group to show you around. This group is public, but other groups can be private",
url: "/",
alignX: "right",
alignY: "top",
offsetX: MODAL_WIDTH + 8,
offsetY: 0,
},
"group-desc": {
title: "What's a group",
description:
"A group contains members and tends to be centered around a topic or multiple topics.",
url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}`,
alignX: "left",
alignY: "top",
offsetX: MODAL_WIDTH + 8,
offsetY: MODAL_HEIGHT / 2 - 8,
},
channels: {
title: "Channels",
description:
"Inside a group you have three types of Channels: Chat, Collection, or Notebook. Mix and match these depending on your group context!",
url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}`,
alignY: "top",
alignX: "right",
offsetX: MODAL_WIDTH + 8,
offsetY: -8,
},
chat: {
title: "Chat",
description:
"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}`,
alignY: "top",
alignX: "right",
offsetX: 0,
offsetY: -32,
},
link: {
title: "Collection",
description:
"A collection is where you can share and view links, images, and other media within your group. Every item in a Collection can have its own comment thread.",
url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}/resource/link/ship/${TUTORIAL_HOST}/${TUTORIAL_LINKS}`,
alignY: "top",
alignX: "right",
offsetX: 0,
offsetY: -32,
},
publish: {
title: "Notebook",
description:
"Notebooks are for creating long-form content within your group. Use markdown to create rich posts with headers, lists and images.",
url: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}/resource/publish/ship/${TUTORIAL_HOST}/${TUTORIAL_BOOK}`,
alignY: "top",
alignX: "right",
offsetX: 0,
offsetY: -32,
},
notifications: {
title: "Notifications",
description:
"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: `/~landscape/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}/resource/publish/ship/${TUTORIAL_HOST}/${TUTORIAL_BOOK}/settings#notifications`,
alignY: "top",
alignX: "right",
offsetX: 0,
offsetY: -32,
},
profile: {
title: "Profile",
description:
"Your profile is customizable and can be shared with other ships. Enter as much or as little information as youd like.",
url: `/~profile/~${window.ship}`,
alignY: "top",
alignX: "right",
offsetX: -300 + MODAL_WIDTH / 2,
offsetY: -120 + MODAL_HEIGHT / 2,
},
leap: {
title: "Leap",
description:
"Leap allows you to go to a specific channel, message, collection, profile or group simply by typing in a command or selecting a shortcut from the dropdown menu.",
url: `/~profile/~${window.ship}`,
alignY: "top",
alignX: "left",
offsetX: 0,
offsetY: -32,
},
};

View File

@ -60,6 +60,8 @@ export function useModal(props: UseModalProps): UseModalResult {
display="flex"
alignItems="stretch"
flexDirection="column"
spacing="2"
>
{inner}
</ModalOverlay>

View File

@ -1,13 +1,21 @@
import React, { ReactNode } from "react";
import f from 'lodash/fp';
import create, { State } from 'zustand';
import { persist } from 'zustand/middleware';
import produce from 'immer';
import { BackgroundConfig, RemoteContentPolicy } from "~/types/local-update";
import { BackgroundConfig, RemoteContentPolicy, TutorialProgress, tutorialProgress } from "~/types/local-update";
export interface LocalState extends State {
hideAvatars: boolean;
hideNicknames: boolean;
remoteContentPolicy: RemoteContentPolicy;
tutorialProgress: TutorialProgress;
tutorialRef: HTMLElement | null,
hideTutorial: () => void;
nextTutStep: () => void;
prevTutStep: () => void;
setTutorialRef: (el: HTMLElement | null) => void;
dark: boolean;
background: BackgroundConfig;
omniboxShown: boolean;
@ -15,12 +23,35 @@ export interface LocalState extends State {
toggleOmnibox: () => void;
set: (fn: (state: LocalState) => void) => void
};
export const selectLocalState =
<K extends keyof LocalState>(keys: K[]) => f.pick<LocalState, K>(keys);
const useLocalState = create<LocalState>(persist((set, get) => ({
dark: false,
background: undefined,
hideAvatars: false,
hideNicknames: false,
tutorialProgress: 'hidden',
tutorialRef: null,
setTutorialRef: (el: HTMLElement | null) => set(produce(state => {
state.tutorialRef = el;
})),
hideTutorial: () => set(produce(state => {
state.tutorialProgress = 'hidden';
state.tutorialRef = null;
})),
nextTutStep: () => set(produce(state => {
const currIdx = tutorialProgress.findIndex(p => p === state.tutorialProgress)
if(currIdx < tutorialProgress.length) {
state.tutorialProgress = tutorialProgress[currIdx + 1];
}
})),
prevTutStep: () => set(produce(state => {
const currIdx = tutorialProgress.findIndex(p => p === state.tutorialProgress)
if(currIdx > 0) {
state.tutorialProgress = tutorialProgress[currIdx - 1];
}
})),
remoteContentPolicy: {
imageShown: true,
audioShown: true,
@ -41,7 +72,10 @@ const useLocalState = create<LocalState>(persist((set, get) => ({
})),
set: fn => set(produce(fn))
}), {
blacklist: ['suspendedFocus', 'toggleOmnibox', 'omniboxShown'],
blacklist: [
'suspendedFocus', 'toggleOmnibox', 'omniboxShown', 'tutorialProgress',
'prevTutStep', 'nextTutStep', 'tutorialRef', 'setTutorialRef'
],
name: 'localReducer'
}));

View File

@ -1,3 +1,6 @@
export const tutorialProgress = ['hidden', 'start', 'group-desc', 'channels', 'chat', 'link', 'publish', 'notifications', 'profile', 'leap', 'done'] as const;
export type TutorialProgress = typeof tutorialProgress[number];
interface LocalUpdateSetDark {
setDark: boolean;
}
@ -48,4 +51,4 @@ export type LocalUpdate =
| LocalUpdateHideAvatars
| LocalUpdateHideNicknames
| LocalUpdateSetOmniboxShown
| RemoteContentPolicy;
| RemoteContentPolicy;

View File

@ -20,6 +20,7 @@ import { Content } from './landscape/components/Content';
import StatusBar from './components/StatusBar';
import Omnibox from './components/leap/Omnibox';
import ErrorBoundary from '~/views/components/ErrorBoundary';
import { TutorialModal } from '~/views/landscape/components/TutorialModal';
import GlobalStore from '~/logic/store/store';
import GlobalSubscription from '~/logic/subscription/global';
@ -149,6 +150,7 @@ class App extends React.Component {
</Helmet>
<Root background={background}>
<Router>
<TutorialModal api={this.api} />
<ErrorBoundary>
<StatusBarWithRouter
props={this.props}

View File

@ -1,19 +1,33 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { useHistory } from 'react-router-dom';
import f from 'lodash/fp';
import _ from 'lodash';
import { Box, Row, Icon, Text } from '@tlon/indigo-react';
import { Col, Button, Box, Row, Icon, Text } from '@tlon/indigo-react';
import './css/custom.css';
import Tiles from './components/tiles';
import Tile from './components/tiles/tile';
import Welcome from './components/welcome';
import Groups from './components/Groups';
import ModalButton from './components/ModalButton';
import { StatelessAsyncButton } from '~/views/components/StatelessAsyncButton';
import { writeText } from '~/logic/lib/util';
import { useModal } from "~/logic/lib/useModal";
import { NewGroup } from "~/views/landscape/components/NewGroup";
import { JoinGroup } from "~/views/landscape/components/JoinGroup";
import { Helmet } from 'react-helmet';
import useLocalState from "~/logic/state/local";
import { useWaitForProps } from '~/logic/lib/useWaitForProps';
import {
hasTutorialGroup,
TUTORIAL_GROUP,
TUTORIAL_HOST,
TUTORIAL_BOOK,
TUTORIAL_CHAT,
TUTORIAL_LINKS
} from '~/logic/lib/tutorialModal';
const ScrollbarLessBox = styled(Box)`
scrollbar-width: none !important;
@ -23,7 +37,10 @@ const ScrollbarLessBox = styled(Box)`
}
`;
const tutSelector = f.pick(['tutorialProgress', 'nextTutStep']);
export default function LaunchApp(props) {
const history = useHistory();
const [hashText, setHashText] = useState(props.baseHash);
const hashBox = (
<Box
@ -51,13 +68,68 @@ export default function LaunchApp(props) {
<Text color="gray">{hashText || props.baseHash}</Text>
</Box>
);
const { tutorialProgress, nextTutStep } = useLocalState(tutSelector);
const waiter = useWaitForProps(props);
const { modal, showModal } = useModal({
modal: (dismiss) => {
const onDismiss = (e) => {
e.stopPropagation();
props.api.settings.putEntry('tutorial', 'seen', true);
dismiss();
};
const onContinue = async (e) => {
e.stopPropagation();
if(!hasTutorialGroup(props)) {
await props.api.groups.join(TUTORIAL_HOST, TUTORIAL_GROUP);
await waiter(hasTutorialGroup);
await Promise.all(
[TUTORIAL_BOOK, TUTORIAL_CHAT, TUTORIAL_LINKS].map(graph =>
api.graph.join(TUTORIAL_HOST, graph)));
await waiter(p => {
return `/ship/${TUTORIAL_HOST}/${TUTORIAL_CHAT}` in p.associations.graph &&
`/ship/${TUTORIAL_HOST}/${TUTORIAL_BOOK}` in p.associations.graph &&
`/ship/${TUTORIAL_HOST}/${TUTORIAL_LINKS}` in p.associations.graph;
});
}
nextTutStep();
dismiss();
}
return (
<Col gapY="2" p="3">
<Text lineHeight="tall" fontWeight="medium">Welcome</Text>
<Text lineHeight="tall">
You have been invited to use Landscape, an interface to chat
and interact with communities
<br />
Would you like a tour of Landscape?
</Text>
<Row gapX="2" justifyContent="flex-end">
<Button onClick={onDismiss}>Skip</Button>
<StatelessAsyncButton primary onClick={onContinue}>
Yes
</StatelessAsyncButton>
</Row>
</Col>
)}
});
useEffect(() => {
const seenTutorial = _.get(props.settings, ['tutorial', 'seen'], true);
if(!seenTutorial && tutorialProgress === 'hidden') {
showModal();
}
}, [props.settings]);
return (
<>
<Helmet defer={false}>
<title>{ props.notificationsCount ? `(${String(props.notificationsCount) }) `: '' }Landscape</title>
</Helmet>
<ScrollbarLessBox height='100%' overflowY='scroll' display="flex" flexDirection="column">
<Welcome firstTime={props.launch.firstTime} api={props.api} />
{modal}
<Box
mx='2'
display='grid'

View File

@ -1,4 +1,4 @@
import React from "react";
import React, {useRef} from "react";
import { Box, Text, Col } from "@tlon/indigo-react";
import f from "lodash/fp";
import _ from "lodash";
@ -7,6 +7,8 @@ import { Associations, Association, Unreads, UnreadStats } from "~/types";
import { alphabeticalOrder } from "~/logic/lib/util";
import { getUnreadCount, getNotificationCount } from "~/logic/lib/hark";
import Tile from "../components/tiles/tile";
import { useTutorialModal } from "~/views/components/useTutorialModal";
import {TUTORIAL_HOST, TUTORIAL_GROUP} from "~/logic/lib/tutorialModal";
interface GroupsProps {
associations: Associations;
@ -73,8 +75,15 @@ interface GroupProps {
}
function Group(props: GroupProps) {
const { path, title, unreads, updates, first = false } = props;
const anchorRef = useRef<HTMLElement>(null);
const isTutorialGroup = path === `/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}`;
useTutorialModal(
'start',
isTutorialGroup,
anchorRef.current
);
return (
<Tile to={`/~landscape${path}`} gridColumnStart={first ? '1' : null}>
<Tile ref={anchorRef} position="relative" bg={isTutorialGroup ? 'lightBlue' : undefined} to={`/~landscape${path}`} gridColumnStart={first ? '1' : null}>
<Col height="100%" justifyContent="space-between">
<Text>{title}</Text>
<Col>

View File

@ -22,43 +22,45 @@ const SquareBox = styled(Box)`
`;
const routeList = defaultApps.map(a => `/~${a}`);
export default class Tile extends React.Component {
render() {
const { bg, to, href, p, boxShadow, gridColumnStart, ...props } = this.props;
const Tile = React.forwardRef((props, ref) => {
const { bg, to, href, p, boxShadow, gridColumnStart, ...rest } = props;
let childElement = (
<Box p={typeof p === 'undefined' ? 2 : p} width="100%" height="100%">
{props.children}
</Box>
);
if (to) {
if (routeList.indexOf(to) !== -1 || to === '/~profile' || to.startsWith('/~landscape/')) {
childElement= (<Link to={to}>{childElement}</Link>);
} else {
childElement= (<a href={to}>{childElement}</a>);
}
let childElement = (
<Box p={typeof p === 'undefined' ? 2 : p} width="100%" height="100%">
{props.children}
</Box>
);
if (to) {
if (routeList.indexOf(to) !== -1 || to === '/~profile' || to.startsWith('/~landscape/')) {
childElement= (<Link to={to}>{childElement}</Link>);
} else {
childElement= (<a href={to}>{childElement}</a>);
}
return (
<SquareBox
borderRadius={2}
overflow="hidden"
bg={bg || "white"}
color={props?.color || 'washedGray'}
boxShadow={boxShadow || '0 0 0px 1px inset'}
style={{ gridColumnStart }}
>
<Box
{...props}
height="100%"
width="100%"
>
{childElement}
</Box>
</SquareBox>
);
}
}
return (
<SquareBox
ref={ref}
position="relative"
borderRadius={2}
overflow="hidden"
bg={bg || "white"}
color={props?.color || 'washedGray'}
boxShadow={boxShadow || '0 0 0px 1px inset'}
style={{ gridColumnStart }}
>
<Box
{...rest}
height="100%"
width="100%"
>
{childElement}
</Box>
</SquareBox>
);
});
export default Tile;

View File

@ -1,49 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Box, Text } from '@tlon/indigo-react';
export default class Welcome extends React.Component {
constructor() {
super();
this.state = {
show: true
};
this.disableWelcome = this.disableWelcome.bind(this);
}
disableWelcome() {
this.props.api.launch.changeFirstTime(false);
this.setState({ show: false });
}
render() {
const firstTime = this.props.firstTime;
return (firstTime && this.state.show) ? (
<Box
bg='white'
border={1}
margin={3}
padding={3}
display='flex'
flexDirection='column'
alignItems='flex-start'
>
<Text>Welcome. This virtual computer belongs to you completely. The Urbit ID you used to boot it is yours as well.</Text>
<Text pt={2}>Since your ID and OS belong to you, its up to you to keep them safe. Be sure your ID is somewhere you wont lose it and you keep your OS on a machine you trust.</Text>
<Text pt={2}>Urbit OS is designed to keep your data secure and hard to lose. But the system is still young so dont put anything critical in here just yet.</Text>
<Text pt={2}>To begin exploring, you should probably pop into a chat and verify there are signs of life in this new place. If you were invited by a friend, you probably already have access to a few groups.</Text>
<Text pt={2}>If you don't know where to go, feel free to <Link className="no-underline bb b--black b--gray1-d dib" to="/~landscape/join/~bitbet-bolbel/urbit-community">join the Urbit Community group</Link>.
</Text>
<Text pt={2}>Have fun!</Text>
<Text pt={2} className='pointer bb'
onClick={(() => {
this.disableWelcome();
})}
>
Close this note
</Text>
</Box>
) : null;
}
}

View File

@ -1,4 +1,4 @@
import React, {useEffect} from "react";
import React, {useEffect, useRef} from "react";
import { Sigil } from "~/logic/lib/sigil";
import { ViewProfile } from './ViewProfile';
import { EditProfile } from './EditProfile';
@ -15,6 +15,7 @@ import {
} from "@tlon/indigo-react";
import useLocalState from "~/logic/state/local";
import { useHistory } from "react-router-dom";
import {useTutorialModal} from "~/views/components/useTutorialModal";
export function Profile(props: any) {
@ -43,6 +44,10 @@ export function Profile(props: any) {
? <BaseImage src={contact.avatar} width='100%' height='100%' style={{ objectFit: 'cover' }} />
: <Sigil padding={24} ship={ship} size={128} color={hexColor} />;
const anchorRef = useRef<HTMLElement | null>(null);
useTutorialModal('profile', ship === `~${window.ship}`, anchorRef.current);
return (
<Center
p={4}
@ -55,7 +60,7 @@ export function Profile(props: any) {
<SetStatus ship={ship} contact={contact} api={props.api} />
) : null
}
<Row width="100%" height="300px">
<Row ref={anchorRef} width="100%" height="300px">
{cover}
</Row>
<Row

View File

@ -11,9 +11,7 @@ import { Box, Col } from "@tlon/indigo-react";
import { useOutsideClick } from "~/logic/lib/useOutsideClick";
import { useLocation } from "react-router-dom";
import { Portal } from "./Portal";
type AlignY = "top" | "bottom";
type AlignX = "left" | "right";
import { getRelativePosition, AlignY, AlignX } from "~/logic/lib/relativePosition";
interface DropdownProps {
children: ReactNode;
@ -44,46 +42,11 @@ export function Dropdown(props: DropdownProps) {
const [coords, setCoords] = useState({});
const updatePos = useCallback(() => {
const rect = anchorRef.current?.getBoundingClientRect();
if (rect) {
const bounds = {
top: rect.top,
left: rect.left,
bottom: document.documentElement.clientHeight - rect.bottom,
right: document.documentElement.clientWidth - rect.right,
};
const alignX = _.isArray(props.alignX) ? props.alignX : [props.alignX];
const alignY = _.isArray(props.alignY) ? props.alignY : [props.alignY];
let newCoords = {
..._.reduce(
alignX,
(acc, a, idx) => ({
...acc,
[a]: _.zipWith(
[...Array(idx), `${bounds[a]}px`],
acc[a] || [],
(a, b) => a || b || null
),
}),
{}
),
..._.reduce(
alignY,
(acc, a, idx) => ({
...acc,
[a]: _.zipWith(
[...Array(idx), `${bounds[a]}px`],
acc[a] || [],
(a, b) => a || b || null
),
}),
{}
),
};
const newCoords = getRelativePosition(anchorRef.current, props.alignX, props.alignY);
if(newCoords) {
setCoords(newCoords);
}
}, [setCoords, anchorRef.current]);
}, [setCoords, anchorRef.current, props.alignY, props.alignX]);
useEffect(() => {
if (!open) {

View File

@ -21,12 +21,12 @@ interface HoverBoxLinkProps {
to: string;
}
export const HoverBoxLink = ({
export const HoverBoxLink = React.forwardRef(({
to,
children,
...rest
}: HoverBoxLinkProps & PropFunc<typeof HoverBox>) => (
<Link to={to}>
}: HoverBoxLinkProps & PropFunc<typeof HoverBox>, ref) => (
<Link ref={ref} to={to}>
<HoverBox {...rest}>{children}</HoverBox>
</Link>
);
));

View File

@ -1,6 +1,7 @@
import React, {
useState,
useEffect
useEffect,
useRef
} from 'react';
import {
@ -18,6 +19,7 @@ import { StatusBarItem } from './StatusBarItem';
import { Sigil } from '~/logic/lib/sigil';
import { uxToHex } from "~/logic/lib/util";
import { SetStatusBarModal } from './SetStatusBarModal';
import { useTutorialModal } from './useTutorialModal';
import useLocalState from '~/logic/state/local';
@ -43,6 +45,10 @@ const StatusBar = (props) => {
style={{ objectFit: 'cover' }} />
) : <Sigil ship={ship} size={16} color={color} icon />;
const anchorRef = useRef(null);
useTutorialModal('leap', true, anchorRef.current);
return (
<Box
display='grid'
@ -64,7 +70,7 @@ const StatusBar = (props) => {
</Box>
)}
<Icon icon='LeapArrow'/>
<Text ml={2} color='black'>
<Text ref={anchorRef} ml={2} color='black'>
Leap
</Text>
<Text display={['none', 'inline']} ml={2} color='gray'>

View File

@ -0,0 +1,21 @@
import { useEffect } from "react";
import { TutorialProgress } from "~/types";
import useLocalState, { selectLocalState } from "~/logic/state/local";
const localSelector = selectLocalState(["tutorialProgress", "setTutorialRef"]);
export function useTutorialModal(
onProgress: TutorialProgress,
show: boolean,
anchorRef: HTMLElement | null
) {
const { tutorialProgress, setTutorialRef } = useLocalState(localSelector);
useEffect(() => {
if (show && onProgress === tutorialProgress && anchorRef) {
setTutorialRef(anchorRef);
}
}, [onProgress, tutorialProgress, show, anchorRef]);
return show && onProgress === tutorialProgress;
}

View File

@ -1,8 +1,9 @@
import React from "react";
import React, {useRef} from "react";
import { Col, Text, BaseLabel, Label } from "@tlon/indigo-react";
import GlobalApi from "~/logic/api/global";
import { Association, NotificationGraphConfig } from "~/types";
import { StatelessAsyncToggle } from "~/views/components/StatelessAsyncToggle";
import {useTutorialModal} from "~/views/components/useTutorialModal";
interface ChannelNotificationsProps {
api: GlobalApi;
@ -24,9 +25,13 @@ export function ChannelNotifications(props: ChannelNotificationsProps) {
await api.hark[func](rid, "/");
};
const anchorRef = useRef<HTMLElement | null>(null)
useTutorialModal('notifications', true, anchorRef.current);
return (
<Col mb="6" gapY="4" flexShrink={0}>
<Text id="notifications" fontSize="2" fontWeight="bold">
<Text ref={anchorRef} id="notifications" fontSize="2" fontWeight="bold">
Channel Notifications
</Text>
<BaseLabel display="flex" cursor="pointer">

View File

@ -1,19 +1,28 @@
import React, { ReactNode } from "react";
import React, { ReactNode, useRef } from "react";
import { Metadata } from "~/types";
import { Col, Row, Text } from "@tlon/indigo-react";
import { MetadataIcon } from "./MetadataIcon";
import { useTutorialModal } from "~/views/components/useTutorialModal";
import {TUTORIAL_HOST, TUTORIAL_GROUP} from "~/logic/lib/tutorialModal";
interface GroupSummaryProps {
metadata: Metadata;
memberCount: number;
channelCount: number;
resource?: string;
children?: ReactNode;
}
export function GroupSummary(props: GroupSummaryProps) {
const { channelCount, memberCount, metadata, children } = props;
const { channelCount, memberCount, metadata, resource, children } = props;
const anchorRef = useRef<HTMLElement | null>(null);
useTutorialModal(
"group-desc",
resource === `/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}`,
anchorRef.current
);
return (
<Col maxWidth="300px" gapY="4">
<Col ref={anchorRef} maxWidth="300px" gapY="4">
<Row gapX="2">
<MetadataIcon
borderRadius="1"

View File

@ -199,6 +199,7 @@ export function GroupsPane(props: GroupsPaneProps) {
memberCount={memberCount}
channelCount={0}
metadata={groupAssociation.metadata}
resource={groupAssociation.group}
/>
} else {
summary = (<Box p="4"><Text fontSize="0" color='gray'>

View File

@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useRef } from 'react';
import styled from 'styled-components';
import {
Col
@ -19,6 +19,7 @@ import { getGroupFromWorkspace } from '~/logic/lib/workspace';
import { SidebarAppConfigs } from './types';
import { SidebarList } from './SidebarList';
import { roleForShip } from '~/logic/lib/group';
import {useTutorialModal} from '~/views/components/useTutorialModal';
const ScrollbarLessCol = styled(Col)`
scrollbar-width: none !important;
@ -64,8 +65,12 @@ export function Sidebar(props: SidebarProps) {
const role = props.groups?.[groupPath] ? roleForShip(props.groups[groupPath], window.ship) : undefined;
const isAdmin = (role === 'admin') || (workspace?.type === 'home');
const anchorRef = useRef<HTMLElement | null>(null);
useTutorialModal('channels', true, anchorRef.current);
return (
<ScrollbarLessCol
ref={anchorRef}
display={display}
width="100%"
gridRow="1/2"

View File

@ -1,4 +1,4 @@
import React from "react";
import React, {useRef} from "react";
import _ from 'lodash';
import { Icon, Row, Box, Text, BaseImage } from "@tlon/indigo-react";
@ -9,6 +9,8 @@ import { Groups, Association } from "~/types";
import { Sigil } from '~/logic/lib/sigil';
import urbitOb from 'urbit-ob';
import { getModuleIcon, getItemTitle, uxToHex } from "~/logic/lib/util";
import {useTutorialModal} from "~/views/components/useTutorialModal";
import {TUTORIAL_HOST, TUTORIAL_GROUP} from "~/logic/lib/tutorialModal";
function SidebarItemIndicator(props: { status?: SidebarItemStatus }) {
switch (props.status) {
@ -41,6 +43,12 @@ export function SidebarItem(props: {
const mod = association?.metadata?.module || appName;
const rid = association?.resource
const groupPath = association?.group;
const anchorRef = useRef<HTMLElement | null>(null)
useTutorialModal(
mod as any,
groupPath === `/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}`,
anchorRef.current
);
const app = apps[appName];
const isUnmanaged = groups?.[groupPath]?.hidden || false;
if (!app) {
@ -87,6 +95,7 @@ export function SidebarItem(props: {
return (
<HoverBoxLink
ref={anchorRef}
to={to}
bg="white"
bgActive="washedGray"

View File

@ -0,0 +1,189 @@
import React, { useState, useEffect, useCallback } from "react";
import { Box, Col, Row, Button, Text, Icon, Action } from "@tlon/indigo-react";
import { useHistory } from "react-router-dom";
import { TutorialProgress, tutorialProgress as progress } from "~/types";
import { Portal } from "~/views/components/Portal";
import useLocalState, { selectLocalState } from "~/logic/state/local";
import {
progressDetails,
MODAL_HEIGHT_PX,
MODAL_WIDTH_PX,
MODAL_WIDTH,
MODAL_HEIGHT,
TUTORIAL_HOST,
TUTORIAL_GROUP,
} from "~/logic/lib/tutorialModal";
import { getRelativePosition } from "~/logic/lib/relativePosition";
import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton";
import GlobalApi from "~/logic/api/global";
const localSelector = selectLocalState([
"tutorialProgress",
"nextTutStep",
"prevTutStep",
"tutorialRef",
"hideTutorial",
]);
export function TutorialModal(props: { api: GlobalApi }) {
const {
tutorialProgress,
tutorialRef,
nextTutStep,
prevTutStep,
hideTutorial,
} = useLocalState(localSelector);
const {
title,
description,
alignX,
alignY,
offsetX,
offsetY,
} = progressDetails[tutorialProgress];
const [coords, setCoords] = useState({});
const history = useHistory();
const next = useCallback(
(e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.stopPropagation();
const idx = progress.findIndex((p) => p === tutorialProgress);
const { url } = progressDetails[progress[idx + 1]];
history.push(url);
nextTutStep();
},
[nextTutStep, history, tutorialProgress, setCoords]
);
const prev = useCallback(() => {
const idx = progress.findIndex((p) => p === tutorialProgress);
history.push(progressDetails[progress[idx - 1]].url);
prevTutStep();
}, [prevTutStep, history, tutorialProgress]);
const updatePos = useCallback(() => {
const newCoords = getRelativePosition(
tutorialRef,
alignX,
alignY,
offsetX,
offsetY
);
const withMobile: any = _.mapValues(newCoords, (value: string[], key: string) => {
if(key === 'bottom' || key === 'left') {
return ['0px', ...value];
}
return [null, ...value];
});
if(!('bottom' in withMobile)) {
withMobile.bottom = ['0px', null];
}
if(!('left' in withMobile)) {
withMobile.left = ['0px', null];
}
if (newCoords) {
setCoords(withMobile);
}
}, [tutorialRef]);
const dismiss = useCallback(() => {
hideTutorial();
props.api.settings.putEntry("tutorial", "seen", true);
}, [hideTutorial, props.api]);
const leaveGroup = useCallback(async () => {
await props.api.groups.leaveGroup(TUTORIAL_HOST, TUTORIAL_GROUP);
}, [props.api]);
const progressIdx = progress.findIndex((p) => p === tutorialProgress);
useEffect(() => {
if (
tutorialProgress !== "hidden" &&
tutorialProgress !== "done" &&
tutorialRef
) {
const interval = setInterval(updatePos, 100);
return () => {
clearInterval(interval);
};
}
return () => {};
}, [tutorialRef, tutorialProgress, updatePos]);
// manually center final window
useEffect(() => {
if (tutorialProgress === "done") {
const { innerWidth, innerHeight } = window;
const left = ["0px", `${(innerWidth - MODAL_WIDTH) / 2}px`];
const top = [null, `${(innerHeight - MODAL_HEIGHT) / 2}px`];
const bottom = ["0px", null];
setCoords({ top, left, bottom });
}
}, [tutorialProgress]);
if (tutorialProgress === "hidden") {
return null;
}
return (
<Portal>
<Box
position="fixed"
{...coords}
bg="white"
zIndex={50}
height={MODAL_HEIGHT_PX}
width={["100%", MODAL_WIDTH_PX]}
borderRadius="2"
>
<Col
position="relative"
justifyContent="space-between"
height="100%"
width="100%"
borderRadius="2"
p="2"
bg="lightBlue"
>
<Box
right="8px"
top="8px"
position="absolute"
cursor="pointer"
onClick={dismiss}
>
<Icon icon="X" />
</Box>
<Text lineHeight="tall" fontWeight="medium">
{title}
</Text>
<Text lineHeight="tall">{description}</Text>
{tutorialProgress !== "done" ? (
<Row justifyContent="space-between">
<Action bg="transparent" onClick={prev}>
<Icon icon="ArrowWest" />
</Action>
<Text>
{progressIdx}/{progress.length - 1}
</Text>
<Action bg="transparent" onClick={next}>
<Icon icon="ArrowEast" />
</Action>
</Row>
) : (
<Row justifyContent="space-between">
<StatelessAsyncButton primary onClick={leaveGroup}>
Leave Group
</StatelessAsyncButton>
<Button onClick={dismiss}>Later</Button>
</Row>
)}
</Col>
</Box>
</Portal>
);
}