feat: desktop version

This commit is contained in:
Morten Olsen
2023-06-20 12:17:40 +02:00
parent fec30cc430
commit 6477b56ade
17 changed files with 2337 additions and 65 deletions

1
.npmrc
View File

@@ -1,2 +1,3 @@
node-linker=hoisted node-linker=hoisted
public-hoist-pattern=*
store-dir=.pnpm-store store-dir=.pnpm-store

View File

@@ -1,4 +1,10 @@
import { createContext, useCallback, useMemo, useState } from 'react'; import {
createContext,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import type { Notification } from './types'; import type { Notification } from './types';
type NotificationsContextValue = { type NotificationsContextValue = {
@@ -9,6 +15,7 @@ type NotificationsContextValue = {
type NotificationsProviderProps = { type NotificationsProviderProps = {
children: React.ReactNode; children: React.ReactNode;
onNotificationsUpdate?: (notification: Notification[]) => void;
}; };
const NotificationsContext = createContext<NotificationsContextValue | null>( const NotificationsContext = createContext<NotificationsContextValue | null>(
@@ -19,6 +26,7 @@ let nextId = 0;
const NotificationsProvider: React.FC<NotificationsProviderProps> = ({ const NotificationsProvider: React.FC<NotificationsProviderProps> = ({
children, children,
onNotificationsUpdate,
}) => { }) => {
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
@@ -55,6 +63,10 @@ const NotificationsProvider: React.FC<NotificationsProviderProps> = ({
[notifications, add, dismiss], [notifications, add, dismiss],
); );
useEffect(() => {
onNotificationsUpdate?.(notifications);
}, [notifications, onNotificationsUpdate]);
return ( return (
<NotificationsContext.Provider value={value}> <NotificationsContext.Provider value={value}>
{children} {children}

View File

@@ -6,7 +6,7 @@ import {
SlackLogin, SlackLogin,
} from './clients'; } from './clients';
import { Widget, WidgetsProvider } from './widgets'; import { Widget, WidgetsProvider } from './widgets';
import { NotificationsProvider } from './notifications'; import { NotificationsProvider, Notification } from './notifications';
import { BoardsLoad, BoardsProvider, BoardsSave } from './boards'; import { BoardsLoad, BoardsProvider, BoardsSave } from './boards';
type DashboardProviderProps = { type DashboardProviderProps = {
@@ -14,6 +14,7 @@ type DashboardProviderProps = {
widgets?: Widget<TSchema>[]; widgets?: Widget<TSchema>[];
load: BoardsLoad; load: BoardsLoad;
save: BoardsSave; save: BoardsSave;
onNotificationsUpdate?: (notifications: Notification[]) => void;
logins: { logins: {
github: GithubLogin; github: GithubLogin;
linear: LinearLogin; linear: LinearLogin;
@@ -27,10 +28,11 @@ const DashboardProvider: React.FC<DashboardProviderProps> = ({
load, load,
save, save,
logins, logins,
onNotificationsUpdate,
}) => ( }) => (
<WidgetsProvider widgets={widgets}> <WidgetsProvider widgets={widgets}>
<BoardsProvider load={load} save={save}> <BoardsProvider load={load} save={save}>
<NotificationsProvider> <NotificationsProvider onNotificationsUpdate={onNotificationsUpdate}>
<ClientProvider logins={logins}>{children}</ClientProvider> <ClientProvider logins={logins}>{children}</ClientProvider>
</NotificationsProvider> </NotificationsProvider>
</BoardsProvider> </BoardsProvider>

View File

@@ -11,3 +11,4 @@ export * from './masonry';
export * from './code-editor'; export * from './code-editor';
export * from './popover'; export * from './popover';
export * from './form'; export * from './form';
export * from './loader';

View File

@@ -0,0 +1,37 @@
import styled from 'styled-components';
import { LuLoader2 } from 'react-icons/lu';
type LoaderProps = {
children?: React.ReactNode;
};
const LoaderWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
`;
const LoaderIcon = styled.div`
width: 50px;
height: 50px;
border-radius: 50%;
border: 5px solid #ccc;
border-top-color: #000;
animation: spin 1s infinite linear;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`;
const Loader: React.FC<LoaderProps> = ({ children }) => {
return (
<LoaderWrapper>
<LoaderIcon>{children || <LuLoader2 />}</LoaderIcon>
</LoaderWrapper>
);
};
export { Loader };

View File

@@ -8,10 +8,10 @@ import {
} from '@refocus/sdk'; } from '@refocus/sdk';
import { IoAddCircleOutline } from 'react-icons/io5'; import { IoAddCircleOutline } from 'react-icons/io5';
import styled from 'styled-components'; import styled from 'styled-components';
import { View } from '../../base'; import { Button, Dialog, Form, View } from '../../base';
import { Board } from '../board'; import { Board } from '../board';
import { Tabs } from '../../base/tabs'; import { Tabs } from '../../base/tabs';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
const Wrapper = styled(View)` const Wrapper = styled(View)`
height: 100vh; height: 100vh;
@@ -36,40 +36,68 @@ const Title: React.FC<{ id: string }> = ({ id }) => {
const App: React.FC = () => { const App: React.FC = () => {
const boards = useBoards(); const boards = useBoards();
const [boardName, setBoardName] = useState('');
const [addOpen, setAddOpen] = useState(false);
const selected = useSelectedBoard(); const selected = useSelectedBoard();
const selectBoard = useSelectBoard(); const selectBoard = useSelectBoard();
const addBoardAction = useAddBoard(); const addBoardAction = useAddBoard();
const removeBoard = useRemoveBoard(); const removeBoard = useRemoveBoard();
const addBoard = useCallback(() => { const addBoard = useCallback(
const name = prompt('Board name?'); (name: string) => {
if (!name) { if (!name) {
return; return;
} }
addBoardAction(name); setAddOpen(false);
}, [addBoardAction]); addBoardAction(name);
},
[addBoardAction],
);
return ( return (
<Wrapper $fc> <Wrapper $fc>
<View $f={1}> <View $f={1}>
<Tabs value={selected} onValueChange={selectBoard}> <Tabs value={selected} onValueChange={selectBoard}>
<Tabs.List> <Tabs.List>
{Object.entries(boards).map(([id, board]) => ( {Object.entries(boards).map(([id]) => (
<Tabs.Trigger key={id} value={id}> <Tabs.Trigger key={id} value={id}>
<Title id={id} /> <Title id={id} />
<Tabs.Close onClick={() => removeBoard(id)} /> <Tabs.Close onClick={() => removeBoard(id)} />
</Tabs.Trigger> </Tabs.Trigger>
))} ))}
<View <Dialog open={addOpen} onOpenChange={setAddOpen}>
onClick={addBoard} <Dialog.Trigger>
$fr <View
$justify="center" $fr
$items="center" $justify="center"
$p="md" $items="center"
$bg="highlight100" $p="md"
> $bg="highlight100"
<IoAddCircleOutline size={16} /> >
</View> <IoAddCircleOutline size={16} />
</View>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Form>
<Form.Field label="Board Name">
<Form.Input
value={boardName}
onChange={(e) => setBoardName(e.target.value)}
required
/>
</Form.Field>
<Form.Buttons>
<Button
title="Add Board"
onClick={() => addBoard(boardName)}
/>
</Form.Buttons>
</Form>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
</Tabs.List> </Tabs.List>
{Object.entries(boards).map(([id, board]) => ( {Object.entries(boards).map(([id, board]) => (
<Tabs.Content key={id} value={id}> <Tabs.Content key={id} value={id}>

View File

@@ -1,4 +1,4 @@
import { DashboardProvider, Widget } from '@refocus/sdk'; import { DashboardProvider, Widget, Notification } from '@refocus/sdk';
import { UIProvider } from './theme/provider'; import { UIProvider } from './theme/provider';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { GithubLogin } from './github'; import { GithubLogin } from './github';
@@ -8,9 +8,14 @@ import { SlackLogin } from './slack';
type FocusProviderProps = { type FocusProviderProps = {
children: React.ReactNode; children: React.ReactNode;
widgets: Widget<any>[]; widgets: Widget<any>[];
onNotificationsUpdate?: (notifications: Notification[]) => void;
}; };
const FocusProvider: React.FC<FocusProviderProps> = ({ children, widgets }) => { const FocusProvider: React.FC<FocusProviderProps> = ({
children,
widgets,
onNotificationsUpdate,
}) => {
const save = useCallback((data: any) => { const save = useCallback((data: any) => {
localStorage.setItem('boards', JSON.stringify(data)); localStorage.setItem('boards', JSON.stringify(data));
}, []); }, []);
@@ -42,6 +47,7 @@ const FocusProvider: React.FC<FocusProviderProps> = ({ children, widgets }) => {
save={save} save={save}
widgets={widgets} widgets={widgets}
logins={logins} logins={logins}
onNotificationsUpdate={onNotificationsUpdate}
> >
{children} {children}
</DashboardProvider> </DashboardProvider>

View File

@@ -0,0 +1,44 @@
import { Button, CodeEditor, Form } from '@refocus/ui';
import { useCallback, useState } from 'react';
import { Props } from './schema';
type EditorProps = {
value?: Props;
save: (data: Props) => void;
};
const Edit: React.FC<EditorProps> = ({ value, save }) => {
const [code, setCode] = useState(value?.code || '');
const [language, setLanguage] = useState(value?.language || '');
const [name, setName] = useState(value?.name || '');
const handleSave = useCallback(() => {
save({
name,
code,
language,
});
}, [code, save, language, name]);
return (
<Form>
<Form.Field label="Name">
<Form.Input value={name} onChange={(e) => setName(e.target.value)} />
</Form.Field>
<Form.Field label="Language">
<Form.Input
value={language}
onChange={(e) => setLanguage(e.target.value)}
/>
</Form.Field>
<Form.Field label="Code">
<CodeEditor language={language} value={code} setValue={setCode} />
</Form.Field>
<Form.Buttons>
<Button onClick={handleSave} title="Save" />
</Form.Buttons>
</Form>
);
};
export { Edit };

View File

@@ -0,0 +1,17 @@
import { Widget } from '@refocus/sdk';
import { IoLogoMarkdown } from 'react-icons/io';
import { schema } from './schema';
import { Edit } from './edit';
import { View } from './view';
const widget: Widget<typeof schema> = {
name: 'Code',
description: 'Write a code file',
icon: <IoLogoMarkdown />,
id: 'text.code',
schema,
component: View,
edit: Edit,
};
export default widget;

View File

@@ -0,0 +1,12 @@
import { Type, Static } from '@sinclair/typebox';
const schema = Type.Object({
name: Type.String(),
code: Type.String(),
language: Type.String(),
});
type Props = Static<typeof schema>;
export type { Props };
export { schema };

View File

@@ -0,0 +1,33 @@
import { useName } from '@refocus/sdk';
import { Props } from './schema';
import { useEffect } from 'react';
import { CodeEditor, View } from '@refocus/ui';
import { styled } from 'styled-components';
const FullHeight = styled(View)`
height: 100%;
`;
const StyledCodeEditor = styled(CodeEditor)`
height: 100%;
`;
const WidgetView: React.FC<Props> = ({ code, language, name }) => {
const [, setName] = useName();
useEffect(() => {
setName(name);
}, [name, setName]);
return (
<FullHeight $fc>
<StyledCodeEditor
readOnly
language={language}
value={code}
setValue={() => {}}
/>
</FullHeight>
);
};
export { WidgetView as View };

View File

@@ -3,12 +3,14 @@ import { github } from './github';
import { linear } from './linear'; import { linear } from './linear';
import { slack } from './slack'; import { slack } from './slack';
import markdown from './markdown'; import markdown from './markdown';
import code from './code';
const widgets = [ const widgets = [
...linear, ...linear,
...github, ...github,
...slack, ...slack,
markdown, markdown,
code,
] satisfies Widget<any>[]; ] satisfies Widget<any>[];
export { widgets }; export { widgets };

View File

@@ -25,7 +25,7 @@ const renderElement = (item: Renderable) => {
</a> </a>
); );
case 'user': case 'user':
return <User id={item.user_id} />; return <User key={item.user_id} id={item.user_id} />;
case 'emoji': case 'emoji':
return unicodeToEmoji(item.unicode); return unicodeToEmoji(item.unicode);
case 'rich_text_list': case 'rich_text_list':

View File

@@ -5,7 +5,6 @@ import { render } from '../../block/render';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { WidgetProvider, WidgetView } from '@refocus/sdk'; import { WidgetProvider, WidgetView } from '@refocus/sdk';
import { styled } from 'styled-components'; import { styled } from 'styled-components';
import { User } from '../../block/elements/user';
import { UserAvatar } from '../../block/elements/user-avatar'; import { UserAvatar } from '../../block/elements/user-avatar';
type Message = Exclude< type Message = Exclude<
@@ -57,7 +56,7 @@ const Message: React.FC<Message> = ({
{reaction.name} {reaction.name}
<View $fr> <View $fr>
{reaction.users?.map((user) => ( {reaction.users?.map((user) => (
<UserAvatar id={user} /> <UserAvatar key={user} id={user} />
))} ))}
</View> </View>
</Typography> </Typography>

View File

@@ -9,7 +9,7 @@ import styled from 'styled-components';
import { Props } from './schema'; import { Props } from './schema';
import { ConversationsHistoryResponse } from '@slack/web-api'; import { ConversationsHistoryResponse } from '@slack/web-api';
import { useState } from 'react'; import { useState } from 'react';
import { Chat, Slack, Typography, View } from '@refocus/ui'; import { Chat, Loader, Slack, Typography, View } from '@refocus/ui';
import { User } from '../block/elements/user'; import { User } from '../block/elements/user';
import { Message } from './message/view'; import { Message } from './message/view';
@@ -42,21 +42,23 @@ const WidgetView = withSlack<Props>(({ conversationId, ts }) => {
const [, setName] = useName(); const [, setName] = useName();
const addNotification = useAddWidgetNotification(); const addNotification = useAddWidgetNotification();
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const { fetch, data } = useSlackQuery(async (client, props: Props) => { const { fetch, data, loading } = useSlackQuery(
if (props.ts) { async (client, props: Props) => {
const response = await client.send('conversations.replies', { if (props.ts) {
channel: props.conversationId, const response = await client.send('conversations.replies', {
ts: props.ts, channel: props.conversationId,
}); ts: props.ts,
return response.messages! as MessageType[]; });
} else { return response.messages!.reverse() as MessageType[];
const response = await client.send('conversations.history', { } else {
channel: props.conversationId, const response = await client.send('conversations.history', {
limit: 5, channel: props.conversationId,
}); limit: 5,
return response.messages! as MessageType[]; });
} return response.messages! as MessageType[];
}); }
},
);
const info = useSlackQuery(async (client, props: Props) => { const info = useSlackQuery(async (client, props: Props) => {
const response = await client.send('conversations.info', { const response = await client.send('conversations.info', {
channel: props.conversationId, channel: props.conversationId,
@@ -65,16 +67,7 @@ const WidgetView = withSlack<Props>(({ conversationId, ts }) => {
return response.channel!; return response.channel!;
}); });
const { fetch: post } = useSlackQuery( const update = useAutoUpdate(
async (client, props: PostMessageOptions) => {
client.send('chat.postMessage', {
text: props.message,
channel: conversationId,
});
},
);
useAutoUpdate(
{ {
action: async () => { action: async () => {
await info.fetch({ conversationId, ts }); await info.fetch({ conversationId, ts });
@@ -103,10 +96,22 @@ const WidgetView = withSlack<Props>(({ conversationId, ts }) => {
[conversationId, ts], [conversationId, ts],
); );
const { fetch: post } = useSlackQuery(
async (client, props: PostMessageOptions) => {
await client.send('chat.postMessage', {
text: props.message,
channel: conversationId,
});
setMessage('');
await update();
},
);
return ( return (
<Wrapper $p="sm" $fc $gap="sm"> <Wrapper $p="sm" $fc $gap="sm">
{loading && <Loader />}
<MessageList $gap="md" $fc> <MessageList $gap="md" $fc>
{data?.map((message) => { {data?.map((message, index) => {
if ('subtype' in message && message.subtype === 'channel_join') { if ('subtype' in message && message.subtype === 'channel_join') {
return ( return (
<Typography key={message.ts}> <Typography key={message.ts}>
@@ -116,7 +121,7 @@ const WidgetView = withSlack<Props>(({ conversationId, ts }) => {
} }
return ( return (
<Message <Message
key={message.ts} key={message.ts || index}
{...message} {...message}
conversationId={conversationId} conversationId={conversationId}
/> />

2087
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,10 @@
"name": "app", "name": "app",
"path": "packages/app/src" "path": "packages/app/src"
}, },
{
"name": "desktop",
"path": "packages/desktop/src"
},
{ {
"name": ".", "name": ".",
"path": "." "path": "."