From 99952aa19fc76f272e3ea0743b82d6cb1c9a5d44 Mon Sep 17 00:00:00 2001 From: Katalina Date: Sat, 13 Mar 2021 19:31:36 -0500 Subject: [PATCH] Feat/recent guilds (#89) (#167) * feat(web): add server-setup page for when bot isn't in the server picked * chore: move contexts into their own folder * feat(web): add recent guilds context, and app shell helper context * feat(web): show recent guilds in masthead * feat(web): functionally add recents to servers list * fix(web): correct styling for servers listing recents/all headers * fix(web): correct some type issues with appShellProps * fix(web): don't show ServerListing recents when recents is empty --- .../molecules/guild-nav/GuildNav.tsx | 59 ++++++++++++------- .../organisms/app-shell/AppShell.tsx | 2 + .../organisms/masthead/Authed.tsx | 8 ++- .../servers-listing/ServersListing.styled.ts | 4 ++ .../servers-listing/ServersListing.tsx | 52 ++++++++++------ .../templates/role-picker/RolePicker.tsx | 10 +++- .../templates/servers/Servers.tsx | 10 ++-- packages/misc-utils/guildListing.ts | 26 ++++++++ .../api}/ApiContext.spec.tsx | 0 .../api}/ApiContext.tsx | 0 .../api}/getDefaultApiUrl.spec.ts | 0 .../api}/getDefaultApiUrl.ts | 0 .../contexts/app-shell/AppShellContext.tsx | 41 +++++++++++++ .../recent-guilds/RecentGuildsContext.tsx | 59 +++++++++++++++++++ .../session}/SessionContext.tsx | 2 +- packages/web/src/index.tsx | 31 +++++++--- packages/web/src/pages/auth/login.tsx | 2 +- .../web/src/pages/dev-tools/session-debug.tsx | 4 +- packages/web/src/pages/dev-tools/set-api.tsx | 2 +- packages/web/src/pages/landing.tsx | 6 +- packages/web/src/pages/picker.tsx | 44 ++++++++++++-- packages/web/src/pages/servers.tsx | 6 +- 22 files changed, 302 insertions(+), 66 deletions(-) create mode 100644 packages/misc-utils/guildListing.ts rename packages/web/src/{api-context => contexts/api}/ApiContext.spec.tsx (100%) rename packages/web/src/{api-context => contexts/api}/ApiContext.tsx (100%) rename packages/web/src/{api-context => contexts/api}/getDefaultApiUrl.spec.ts (100%) rename packages/web/src/{api-context => contexts/api}/getDefaultApiUrl.ts (100%) create mode 100644 packages/web/src/contexts/app-shell/AppShellContext.tsx create mode 100644 packages/web/src/contexts/recent-guilds/RecentGuildsContext.tsx rename packages/web/src/{session-context => contexts/session}/SessionContext.tsx (98%) diff --git a/packages/design-system/molecules/guild-nav/GuildNav.tsx b/packages/design-system/molecules/guild-nav/GuildNav.tsx index c6b36a4..0e18460 100644 --- a/packages/design-system/molecules/guild-nav/GuildNav.tsx +++ b/packages/design-system/molecules/guild-nav/GuildNav.tsx @@ -1,5 +1,5 @@ import { NavSlug } from '@roleypoly/design-system/molecules/nav-slug'; -import { sortBy } from '@roleypoly/misc-utils/sortBy'; +import { getRecentAndSortedGuilds } from '@roleypoly/misc-utils/guildListing'; import { GuildSlug, UserGuildPermissions } from '@roleypoly/types'; import * as React from 'react'; import Scrollbars from 'react-custom-scrollbars'; @@ -9,6 +9,7 @@ import { GuildNavItem } from './GuildNav.styled'; type Props = { guilds: GuildSlug[]; + recentGuilds: string[]; }; const tooltipId = 'guildnav'; @@ -27,23 +28,41 @@ const Badges = (props: { guild: GuildSlug }) => { }, [props.guild.permissionLevel]); }; -export const GuildNav = (props: Props) => ( -
- - {sortBy(props.guilds, 'name', (a: string, b: string) => - a.toLowerCase() > b.toLowerCase() ? 1 : -1 - ).map((guild) => ( - - - - - ))} - - -
+const NavList = (props: { guilds: Props['guilds'] }) => ( + <> + {props.guilds.map((guild) => ( + + + + + ))} + ); + +export const GuildNav = (props: Props) => { + const { sortedGuildSlugs, recentGuildSlugs } = getRecentAndSortedGuilds( + props.guilds, + props.recentGuilds + ); + + return ( +
+ + {recentGuildSlugs && ( + <> +
Recents
+ +
All Guilds
+ + )} + + +
+
+ ); +}; diff --git a/packages/design-system/organisms/app-shell/AppShell.tsx b/packages/design-system/organisms/app-shell/AppShell.tsx index c724e4e..7154d57 100644 --- a/packages/design-system/organisms/app-shell/AppShell.tsx +++ b/packages/design-system/organisms/app-shell/AppShell.tsx @@ -13,6 +13,7 @@ export type AppShellProps = { small?: boolean; activeGuildId?: string | null; guilds?: GuildSlug[]; + recentGuilds?: string[]; disableGuildPicker?: boolean; }; @@ -26,6 +27,7 @@ export const AppShell = (props: AppShellProps) => ( guilds={props.guilds || []} activeGuildId={props.activeGuildId || null} user={props.user} + recentGuilds={props.recentGuilds || []} /> ) : ( diff --git a/packages/design-system/organisms/masthead/Authed.tsx b/packages/design-system/organisms/masthead/Authed.tsx index bb21c09..857d340 100644 --- a/packages/design-system/organisms/masthead/Authed.tsx +++ b/packages/design-system/organisms/masthead/Authed.tsx @@ -22,6 +22,7 @@ type Props = { activeGuildId: string | null; guilds: GuildSlug[]; disableGuildPicker?: boolean; + recentGuilds: string[]; }; export const Authed = (props: Props) => { @@ -65,7 +66,12 @@ export const Authed = (props: Props) => { preferredWidth={560} onExit={() => setServerPopoverState(false)} > - {() => } + {() => ( + + )} diff --git a/packages/design-system/organisms/servers-listing/ServersListing.styled.ts b/packages/design-system/organisms/servers-listing/ServersListing.styled.ts index 53cbe65..2982480 100644 --- a/packages/design-system/organisms/servers-listing/ServersListing.styled.ts +++ b/packages/design-system/organisms/servers-listing/ServersListing.styled.ts @@ -21,3 +21,7 @@ export const CardContainer = styled.div` max-width: 30%; `)} `; + +export const SectionHead = styled.div` + flex: 1 1 100%; +`; diff --git a/packages/design-system/organisms/servers-listing/ServersListing.tsx b/packages/design-system/organisms/servers-listing/ServersListing.tsx index 62c9bc1..d1de4b5 100644 --- a/packages/design-system/organisms/servers-listing/ServersListing.tsx +++ b/packages/design-system/organisms/servers-listing/ServersListing.tsx @@ -1,27 +1,45 @@ import { CompletelyStylelessLink } from '@roleypoly/design-system/atoms/typography'; import { ServerListingCard } from '@roleypoly/design-system/molecules/server-listing-card'; -import { sortBy } from '@roleypoly/misc-utils/sortBy'; +import { getRecentAndSortedGuilds } from '@roleypoly/misc-utils/guildListing'; import { GuildSlug } from '@roleypoly/types'; import * as React from 'react'; -import { CardContainer, ContentContainer } from './ServersListing.styled'; +import { CardContainer, ContentContainer, SectionHead } from './ServersListing.styled'; type ServersListingProps = { guilds: GuildSlug[]; + recentGuilds: string[]; }; -export const ServersListing = (props: ServersListingProps) => ( - - {props.guilds && - sortBy(props.guilds, 'name', (a: string, b: string) => - a.toLowerCase() > b.toLowerCase() ? 1 : -1 - ).map((guild, idx) => ( - - - - - - - - ))} - +const CardList = (props: { guilds: GuildSlug[] }) => ( + <> + {props.guilds.map((guild, idx) => ( + + + + + + + + ))} + ); + +export const ServersListing = (props: ServersListingProps) => { + const { recentGuildSlugs, sortedGuildSlugs } = getRecentAndSortedGuilds( + props.guilds, + props.recentGuilds + ); + + return ( + + {recentGuildSlugs.length !== 0 && ( + <> + Recent Guilds + + All Guilds + + )} + + + ); +}; diff --git a/packages/design-system/templates/role-picker/RolePicker.tsx b/packages/design-system/templates/role-picker/RolePicker.tsx index cec5674..ebf3012 100644 --- a/packages/design-system/templates/role-picker/RolePicker.tsx +++ b/packages/design-system/templates/role-picker/RolePicker.tsx @@ -8,9 +8,15 @@ import * as React from 'react'; export type RolePickerTemplateProps = RolePickerProps & Omit; export const RolePickerTemplate = (props: RolePickerTemplateProps) => { - const { user, guilds, activeGuildId, ...pickerProps } = props; + const { user, guilds, activeGuildId, recentGuilds, ...pickerProps } = props; return ( - + ); diff --git a/packages/design-system/templates/servers/Servers.tsx b/packages/design-system/templates/servers/Servers.tsx index 5f8f7e7..71b6574 100644 --- a/packages/design-system/templates/servers/Servers.tsx +++ b/packages/design-system/templates/servers/Servers.tsx @@ -1,14 +1,14 @@ import { AppShell, AppShellProps } from '@roleypoly/design-system/organisms/app-shell'; import { ServersListing } from '@roleypoly/design-system/organisms/servers-listing/ServersListing'; -import { GuildSlug } from '@roleypoly/types'; import * as React from 'react'; -type ServerTemplateProps = Omit & { - guilds: GuildSlug[]; -}; +type ServerTemplateProps = Omit; export const ServersTemplate = (props: ServerTemplateProps) => ( - + ); diff --git a/packages/misc-utils/guildListing.ts b/packages/misc-utils/guildListing.ts new file mode 100644 index 0000000..e838472 --- /dev/null +++ b/packages/misc-utils/guildListing.ts @@ -0,0 +1,26 @@ +import { sortBy } from '@roleypoly/misc-utils/sortBy'; +import { GuildSlug } from '@roleypoly/types'; + +type RecentAndSortedT = { + recentGuildSlugs: GuildSlug[]; + sortedGuildSlugs: GuildSlug[]; +}; + +export const getRecentAndSortedGuilds = ( + guilds: GuildSlug[], + recentGuilds: string[] +): RecentAndSortedT => { + return { + recentGuildSlugs: recentGuilds.reduce((acc, id) => { + const guild = guilds.find((guild) => guild.id === id); + if (guild) { + acc.push(guild); + } + + return acc; + }, []), + sortedGuildSlugs: sortBy(guilds, 'name', (a: string, b: string) => + a.toLowerCase() > b.toLowerCase() ? 1 : -1 + ), + }; +}; diff --git a/packages/web/src/api-context/ApiContext.spec.tsx b/packages/web/src/contexts/api/ApiContext.spec.tsx similarity index 100% rename from packages/web/src/api-context/ApiContext.spec.tsx rename to packages/web/src/contexts/api/ApiContext.spec.tsx diff --git a/packages/web/src/api-context/ApiContext.tsx b/packages/web/src/contexts/api/ApiContext.tsx similarity index 100% rename from packages/web/src/api-context/ApiContext.tsx rename to packages/web/src/contexts/api/ApiContext.tsx diff --git a/packages/web/src/api-context/getDefaultApiUrl.spec.ts b/packages/web/src/contexts/api/getDefaultApiUrl.spec.ts similarity index 100% rename from packages/web/src/api-context/getDefaultApiUrl.spec.ts rename to packages/web/src/contexts/api/getDefaultApiUrl.spec.ts diff --git a/packages/web/src/api-context/getDefaultApiUrl.ts b/packages/web/src/contexts/api/getDefaultApiUrl.ts similarity index 100% rename from packages/web/src/api-context/getDefaultApiUrl.ts rename to packages/web/src/contexts/api/getDefaultApiUrl.ts diff --git a/packages/web/src/contexts/app-shell/AppShellContext.tsx b/packages/web/src/contexts/app-shell/AppShellContext.tsx new file mode 100644 index 0000000..9a18ed0 --- /dev/null +++ b/packages/web/src/contexts/app-shell/AppShellContext.tsx @@ -0,0 +1,41 @@ +import { AppShellProps } from '@roleypoly/design-system/organisms/app-shell'; +import * as React from 'react'; +import { useRecentGuilds } from '../recent-guilds/RecentGuildsContext'; +import { useSessionContext } from '../session/SessionContext'; + +type AppShellPropsT = + | { + user: Required; + guilds: Required; + recentGuilds: Required; + } + | { + user: undefined; + guilds: undefined; + recentGuilds: []; + }; + +export const AppShellPropsContext = React.createContext({ + user: undefined, + guilds: undefined, + recentGuilds: [], +}); + +export const useAppShellProps = () => React.useContext(AppShellPropsContext); + +export const AppShellPropsProvider = (props: { children: React.ReactNode }) => { + const { session } = useSessionContext(); + const { recentGuilds } = useRecentGuilds(); + + const appShellProps: AppShellPropsT = { + user: session?.user, + guilds: session?.guilds, + recentGuilds, + }; + + return ( + + {props.children} + + ); +}; diff --git a/packages/web/src/contexts/recent-guilds/RecentGuildsContext.tsx b/packages/web/src/contexts/recent-guilds/RecentGuildsContext.tsx new file mode 100644 index 0000000..3319e50 --- /dev/null +++ b/packages/web/src/contexts/recent-guilds/RecentGuildsContext.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; + +type RecentGuildsT = { + recentGuilds: string[]; + pushRecentGuild: (id: string) => void; +}; + +export const RecentGuilds = React.createContext({ + recentGuilds: [], + pushRecentGuild: () => {}, +}); + +export const useRecentGuilds = () => React.useContext(RecentGuilds); + +const saveState = (state: string[]) => { + localStorage.setItem('rp_recent_guilds', JSON.stringify(state)); +}; + +const pullState = (): string[] => { + const rawState = localStorage.getItem('rp_recent_guilds'); + if (!rawState) { + return []; + } + + try { + return JSON.parse(rawState); + } catch (e) { + console.warn('RecentGuilds failed to re-hydrate saved state', e); + return []; + } +}; + +export const RecentGuildsProvider = (props: { children: React.ReactNode }) => { + const [recentGuilds, setRecentGuilds] = React.useState(pullState()); + + const recentGuildsData: RecentGuildsT = { + recentGuilds, + pushRecentGuild: (id: string) => { + const nextState = [ + id, + ...recentGuilds.slice(0, 19).filter((guild) => guild !== id), + ]; + + if (recentGuilds[0] !== id) { + setRecentGuilds(nextState); + } + }, + }; + + React.useEffect(() => { + saveState(recentGuilds); + }, [recentGuilds]); + + return ( + + {props.children} + + ); +}; diff --git a/packages/web/src/session-context/SessionContext.tsx b/packages/web/src/contexts/session/SessionContext.tsx similarity index 98% rename from packages/web/src/session-context/SessionContext.tsx rename to packages/web/src/contexts/session/SessionContext.tsx index 511458f..ebedab4 100644 --- a/packages/web/src/session-context/SessionContext.tsx +++ b/packages/web/src/contexts/session/SessionContext.tsx @@ -1,6 +1,6 @@ import { SessionData } from '@roleypoly/types'; import * as React from 'react'; -import { useApiContext } from '../api-context/ApiContext'; +import { useApiContext } from '../api/ApiContext'; type SessionContextT = { session?: Omit, 'tokens'>; diff --git a/packages/web/src/index.tsx b/packages/web/src/index.tsx index bcaef66..1b9b869 100644 --- a/packages/web/src/index.tsx +++ b/packages/web/src/index.tsx @@ -1,16 +1,33 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { ApiContextProvider } from './api-context/ApiContext'; import { AppRouter } from './app-router/AppRouter'; -import { SessionContextProvider } from './session-context/SessionContext'; +import { ApiContextProvider } from './contexts/api/ApiContext'; +import { AppShellPropsProvider } from './contexts/app-shell/AppShellContext'; +import { RecentGuildsProvider } from './contexts/recent-guilds/RecentGuildsContext'; +import { SessionContextProvider } from './contexts/session/SessionContext'; + +const ProviderProvider = (props: { + providerChain: typeof ApiContextProvider[]; + children: React.ReactNode; +}) => { + return props.providerChain.reduceRight( + (acc, Provider) => {acc}, + <>{props.children} + ); +}; ReactDOM.render( - - - - - + + + , document.getElementById('root') ); diff --git a/packages/web/src/pages/auth/login.tsx b/packages/web/src/pages/auth/login.tsx index 408910e..0980dda 100644 --- a/packages/web/src/pages/auth/login.tsx +++ b/packages/web/src/pages/auth/login.tsx @@ -1,7 +1,7 @@ import { AuthLogin } from '@roleypoly/design-system/templates/auth-login'; import { GuildSlug } from '@roleypoly/types'; import React from 'react'; -import { useApiContext } from '../../api-context/ApiContext'; +import { useApiContext } from '../../contexts/api/ApiContext'; const Login = () => { const { apiUrl, fetch } = useApiContext(); diff --git a/packages/web/src/pages/dev-tools/session-debug.tsx b/packages/web/src/pages/dev-tools/session-debug.tsx index 8c6621a..53d30d2 100644 --- a/packages/web/src/pages/dev-tools/session-debug.tsx +++ b/packages/web/src/pages/dev-tools/session-debug.tsx @@ -1,5 +1,5 @@ -import { useApiContext } from '../../api-context/ApiContext'; -import { useSessionContext } from '../../session-context/SessionContext'; +import { useApiContext } from '../../contexts/api/ApiContext'; +import { useSessionContext } from '../../contexts/session/SessionContext'; const SessionDebug = () => { const session = useSessionContext(); diff --git a/packages/web/src/pages/dev-tools/set-api.tsx b/packages/web/src/pages/dev-tools/set-api.tsx index a7d817c..afce059 100644 --- a/packages/web/src/pages/dev-tools/set-api.tsx +++ b/packages/web/src/pages/dev-tools/set-api.tsx @@ -1,6 +1,6 @@ import { navigate } from '@reach/router'; import * as React from 'react'; -import { useApiContext } from '../../api-context/ApiContext'; +import { useApiContext } from '../../contexts/api/ApiContext'; const SetApi = () => { const apiContext = useApiContext(); diff --git a/packages/web/src/pages/landing.tsx b/packages/web/src/pages/landing.tsx index b5b3d86..eb8603d 100644 --- a/packages/web/src/pages/landing.tsx +++ b/packages/web/src/pages/landing.tsx @@ -1,16 +1,18 @@ import { Redirect } from '@reach/router'; import { LandingTemplate } from '@roleypoly/design-system/templates/landing'; import * as React from 'react'; -import { useSessionContext } from '../session-context/SessionContext'; +import { useAppShellProps } from '../contexts/app-shell/AppShellContext'; +import { useSessionContext } from '../contexts/session/SessionContext'; const Landing = () => { const { isAuthenticated } = useSessionContext(); + const appShellProps = useAppShellProps(); if (isAuthenticated) { return ; } - return ; + return ; }; export default Landing; diff --git a/packages/web/src/pages/picker.tsx b/packages/web/src/pages/picker.tsx index 2368c2f..680a0e9 100644 --- a/packages/web/src/pages/picker.tsx +++ b/packages/web/src/pages/picker.tsx @@ -1,8 +1,11 @@ import { Redirect } from '@reach/router'; import { RolePickerTemplate } from '@roleypoly/design-system/templates/role-picker'; +import { ServerSetupTemplate } from '@roleypoly/design-system/templates/server-setup'; import { PresentableGuild, RoleUpdate, UserGuildPermissions } from '@roleypoly/types'; import * as React from 'react'; -import { useSessionContext } from '../session-context/SessionContext'; +import { useAppShellProps } from '../contexts/app-shell/AppShellContext'; +import { useRecentGuilds } from '../contexts/recent-guilds/RecentGuildsContext'; +import { useSessionContext } from '../contexts/session/SessionContext'; import { makeRoleTransactions } from '../utils/roleTransactions'; type PickerProps = { @@ -11,8 +14,12 @@ type PickerProps = { const Picker = (props: PickerProps) => { const { session, authedFetch, isAuthenticated } = useSessionContext(); + const { pushRecentGuild } = useRecentGuilds(); + const appShellProps = useAppShellProps(); - const [pickerData, setPickerData] = React.useState(null); + const [pickerData, setPickerData] = React.useState( + null + ); const [pending, setPending] = React.useState(false); React.useEffect(() => { @@ -20,11 +27,20 @@ const Picker = (props: PickerProps) => { const response = await authedFetch(`/get-picker-data/${props.serverID}`); const data = await response.json(); + if (response.status !== 200) { + setPickerData(false); + return; + } + setPickerData(data); }; fetchPickerData(); - }, [props.serverID, authedFetch]); + }, [props.serverID, authedFetch, pushRecentGuild]); + + React.useCallback((serverID) => pushRecentGuild(serverID), [pushRecentGuild])( + props.serverID + ); if (!isAuthenticated) { return ; @@ -34,6 +50,25 @@ const Picker = (props: PickerProps) => { return
Loading...
; } + if (pickerData === false) { + if (session && session.user && session.guilds) { + const guildSlug = session.guilds.find((guild) => guild.id === props.serverID); + if (!guildSlug) { + throw new Error('placeholder: guild not found in user slugs, 404'); + } + + return ( + + ); + } + + throw new Error('placeholder: session state is odd, 404'); + } + const onSubmit = async (submittedRoles: string[]) => { if (pending === true) { return; @@ -62,8 +97,7 @@ const Picker = (props: PickerProps) => { return ( { const { isAuthenticated, session } = useSessionContext(); + const appShellProps = useAppShellProps(); if (!isAuthenticated || !session) { return ; } - return ; + return ; }; export default ServersPage;