fix: improved Slack widget

This commit is contained in:
Morten Olsen
2023-06-20 08:31:18 +02:00
parent 673df20514
commit fec30cc430
30 changed files with 718 additions and 180 deletions

View File

@@ -5,16 +5,18 @@ type AvatarProps = {
url?: string; url?: string;
name?: string; name?: string;
decal?: React.ReactNode; decal?: React.ReactNode;
size?: 'sm' | 'md' | 'lg'; size?: keyof typeof sizes;
}; };
const sizes = { const sizes = {
xs: 20,
sm: 28, sm: 28,
md: 50, md: 50,
lg: 75, lg: 75,
}; };
const fontSizes = { const fontSizes = {
xs: 8,
sm: 10, sm: 10,
md: 24, md: 24,
lg: 32, lg: 32,

View File

@@ -0,0 +1,29 @@
import { StoryObj, Meta } from '@storybook/react';
import { Form } from '.';
import { Button } from '../button';
type Story = StoryObj<typeof Form>;
const meta = {
title: 'Components/Form',
component: Form,
} satisfies Meta<typeof Form>;
const docs: Story = {
render: () => (
<Form>
<Form.Field label="Owner">
<Form.Input />
</Form.Field>
<Form.Field label="Repo">
<Form.Input />
</Form.Field>
<Form.Buttons>
<Button title="Save" />
</Form.Buttons>
</Form>
),
};
export default meta;
export { docs };

View File

@@ -0,0 +1,62 @@
import styled from 'styled-components';
import { View } from '../view';
import { Typography } from '../../typography';
type RootProps = React.ComponentProps<typeof View> & {
children: React.ReactNode;
};
const Root: React.FC<RootProps> = ({ children, ...props }) => (
<View $fc $gap="sm" {...props}>
{children}
</View>
);
type FieldProps = React.ComponentProps<typeof View> & {
label: string;
children: React.ReactNode;
};
const Label = styled(Typography)`
font-weight: bold;
`;
const FieldWrapper = styled(View)``;
const Field: React.FC<FieldProps> = ({ label, children, ...props }) => (
<FieldWrapper {...props} $fc>
<Label as="label" variant="overline">
{label}
</Label>
{children}
</FieldWrapper>
);
const Input = styled.input`
all: unset;
display: block;
border-radius: 4px;
padding: 8px 12px;
box-shadow: 0 0 0 1px ${({ theme }) => theme.colors.bg.highlight100};
&:focus {
box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.bg.highlight};
}
`;
const Buttons = styled(View)`
display: flex;
justify-content: flex-end;
border-radius: 4px;
overflow: hidden;
padding: 8px 12px;
background-color: ${({ theme }) => theme.colors.bg.highlight100};
`;
const Form = Object.assign(Root, {
Field,
Input,
Buttons,
});
export { Form };

View File

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

View File

@@ -1,5 +1,4 @@
import type { Issue as IssueType } from '@linear/sdk'; import type { Issue as IssueType } from '@linear/sdk';
import { Link } from 'react-router-dom';
import { IssueSearchResult } from '@linear/sdk/dist/_generated_documents'; import { IssueSearchResult } from '@linear/sdk/dist/_generated_documents';
type IssueProps = { type IssueProps = {
@@ -8,9 +7,7 @@ type IssueProps = {
const Issue: React.FC<IssueProps> = ({ issue }) => { const Issue: React.FC<IssueProps> = ({ issue }) => {
return ( return (
<div> <div>
<Link to={`/linear/issue?id=${issue.id}`}>
<h3 className="text-lg font-bold">{issue.title}</h3> <h3 className="text-lg font-bold">{issue.title}</h3>
</Link>
{issue.description} {issue.description}
</div> </div>
); );

View File

@@ -29,6 +29,11 @@ const GlobalStyle = createGlobalStyle`
padding: 0; padding: 0;
${styles.body} ${styles.body}
} }
a {
color: ${({ theme }) => theme.colors.bg.highlight};
text-decoration: none;
}
`; `;
const UIProvider: React.FC<UIProviderProps> = ({ children }) => { const UIProvider: React.FC<UIProviderProps> = ({ children }) => {

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { Props } from './schema'; import { Props } from './schema';
import { Button, Form } from '@refocus/ui';
type EditorProps = { type EditorProps = {
value?: Props; value?: Props;
@@ -24,34 +25,32 @@ const Edit: React.FC<EditorProps> = ({ value, save }) => {
}, [owner, repo, branch, path, highlight, save]); }, [owner, repo, branch, path, highlight, save]);
return ( return (
<div> <Form $fc>
<input <Form.Field label="Owner">
placeholder="Owner" <Form.Input value={owner} onChange={(e) => setOwner(e.target.value)} />
value={owner} </Form.Field>
onChange={(e) => setOwner(e.target.value)} <Form.Field label="Repo">
/> <Form.Input value={repo} onChange={(e) => setRepo(e.target.value)} />
<input </Form.Field>
placeholder="Repo" <Form.Field label="Branch">
value={repo} <Form.Input
onChange={(e) => setRepo(e.target.value)}
/>
<input
placeholder="Branch"
value={branch} value={branch}
onChange={(e) => setBranch(e.target.value)} onChange={(e) => setBranch(e.target.value)}
/> />
<input </Form.Field>
placeholder="Path" <Form.Field label="Path">
value={path} <Form.Input value={path} onChange={(e) => setPath(e.target.value)} />
onChange={(e) => setPath(e.target.value)} </Form.Field>
/> <Form.Field label="Highlights">
<input <Form.Input
placeholder="Highlights"
value={highlight} value={highlight}
onChange={(e) => setHighlight(e.target.value)} onChange={(e) => setHighlight(e.target.value)}
/> />
<button onClick={handleSave}>Save</button> </Form.Field>
</div> <Form.Buttons>
<Button onClick={handleSave} title="Save" />
</Form.Buttons>
</Form>
); );
}; };

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { Props } from './schema'; import { Props } from './schema';
import { Button, Form } from '@refocus/ui';
type EditorProps = { type EditorProps = {
value?: Props; value?: Props;
@@ -20,24 +21,24 @@ const Edit: React.FC<EditorProps> = ({ value, save }) => {
}, [owner, repo, pr, save]); }, [owner, repo, pr, save]);
return ( return (
<div> <Form>
<input <Form.Field label="Owner">
placeholder="Owner" <Form.Input value={owner} onChange={(e) => setOwner(e.target.value)} />
value={owner} </Form.Field>
onChange={(e) => setOwner(e.target.value)} <Form.Field label="Repo">
/> <Form.Input value={repo} onChange={(e) => setRepo(e.target.value)} />
<input </Form.Field>
placeholder="Repo" <Form.Field label="PR">
value={repo} <Form.Input
onChange={(e) => setRepo(e.target.value)}
/>
<input
placeholder="PR" placeholder="PR"
value={pr} value={pr}
onChange={(e) => setPr(e.target.value)} onChange={(e) => setPr(e.target.value)}
/> />
<button onClick={handleSave}>Save</button> </Form.Field>
</div> <Form.Buttons>
<Button onClick={handleSave} title="Save" />
</Form.Buttons>
</Form>
); );
}; };

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { Props } from './schema'; import { Props } from './schema';
import { Button, Form } from '@refocus/ui';
type EditorProps = { type EditorProps = {
value?: Props; value?: Props;
@@ -20,24 +21,20 @@ const Edit: React.FC<EditorProps> = ({ value, save }) => {
}, [owner, repo, pr, save]); }, [owner, repo, pr, save]);
return ( return (
<div> <Form>
<input <Form.Field label="Owner">
placeholder="Owner" <Form.Input value={owner} onChange={(e) => setOwner(e.target.value)} />
value={owner} </Form.Field>
onChange={(e) => setOwner(e.target.value)} <Form.Field label="Repo">
/> <Form.Input value={repo} onChange={(e) => setRepo(e.target.value)} />
<input </Form.Field>
placeholder="Repo" <Form.Field label="PR">
value={repo} <Form.Input value={pr} onChange={(e) => setPr(e.target.value)} />
onChange={(e) => setRepo(e.target.value)} </Form.Field>
/> <Form.Buttons>
<input <Button onClick={handleSave} title="Save" />
placeholder="PR" </Form.Buttons>
value={pr} </Form>
onChange={(e) => setPr(e.target.value)}
/>
<button onClick={handleSave}>Save</button>
</div>
); );
}; };

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { Props } from './schema'; import { Props } from './schema';
import { Button, Form } from '@refocus/ui';
type EditorProps = { type EditorProps = {
value?: Props; value?: Props;
@@ -20,24 +21,20 @@ const Edit: React.FC<EditorProps> = ({ value, save }) => {
}, [owner, repo, id, save]); }, [owner, repo, id, save]);
return ( return (
<div> <Form>
<input <Form.Field label="Owner">
placeholder="Owner" <Form.Input value={owner} onChange={(e) => setOwner(e.target.value)} />
value={owner} </Form.Field>
onChange={(e) => setOwner(e.target.value)} <Form.Field label="Repo">
/> <Form.Input value={repo} onChange={(e) => setRepo(e.target.value)} />
<input </Form.Field>
placeholder="Repo" <Form.Field label="PR">
value={repo} <Form.Input value={id} onChange={(e) => setId(e.target.value)} />
onChange={(e) => setRepo(e.target.value)} </Form.Field>
/> <Form.Buttons>
<input <Button onClick={handleSave} title="Save" />
placeholder="PR" </Form.Buttons>
value={id} </Form>
onChange={(e) => setId(e.target.value)}
/>
<button onClick={handleSave}>Save</button>
</div>
); );
}; };

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { Props } from './schema'; import { Props } from './schema';
import { Button, Form } from '@refocus/ui';
type EditorProps = { type EditorProps = {
value?: Props; value?: Props;
@@ -18,19 +19,17 @@ const Edit: React.FC<EditorProps> = ({ value, save }) => {
}, [owner, repo, save]); }, [owner, repo, save]);
return ( return (
<div> <Form>
<input <Form.Field label="Owner">
placeholder="Owner" <Form.Input value={owner} onChange={(e) => setOwner(e.target.value)} />
value={owner} </Form.Field>
onChange={(e) => setOwner(e.target.value)} <Form.Field label="Repo">
/> <Form.Input value={repo} onChange={(e) => setRepo(e.target.value)} />
<input </Form.Field>
placeholder="Repo" <Form.Buttons>
value={repo} <Button onClick={handleSave} title="Save" />
onChange={(e) => setRepo(e.target.value)} </Form.Buttons>
/> </Form>
<button onClick={handleSave}>Save</button>
</div>
); );
}; };

View File

@@ -22,7 +22,6 @@ const WidgetView = withGithub<Props>(({ owner, repo }) => {
setName(`${params.owner}/${params.repo} workflow runs`); setName(`${params.owner}/${params.repo} workflow runs`);
return response.data.workflow_runs.slice(0, 5); return response.data.workflow_runs.slice(0, 5);
}); });
console.log(data);
useAutoUpdate( useAutoUpdate(
{ {

View File

@@ -1,5 +1,5 @@
import { Widget } from '@refocus/sdk'; import { Widget } from '@refocus/sdk';
import linearMyIssuesWidget from './my-issues/index.widget'; import linearMyIssuesWidget from './my-issues/index';
import linearIssue from './issue'; import linearIssue from './issue';
import linearIssueWithComments from './issue-with-comments'; import linearIssueWithComments from './issue-with-comments';

View File

@@ -0,0 +1,31 @@
import { useCallback, useState } from 'react';
import { Props } from './schema';
import { Button, Form } from '@refocus/ui';
type EditorProps = {
value?: Props;
save: (data: Props) => void;
};
const Edit: React.FC<EditorProps> = ({ value, save }) => {
const [id, setId] = useState(value?.id || '');
const handleSave = useCallback(() => {
save({
id,
});
}, [id, save]);
return (
<Form>
<Form.Field label="Id">
<Form.Input value={id} onChange={(e) => setId(e.target.value)} />
</Form.Field>
<Form.Buttons>
<Button onClick={handleSave} title="Save" />
</Form.Buttons>
</Form>
);
};
export { Edit };

View File

@@ -2,6 +2,7 @@ import { Widget } from '@refocus/sdk';
import { SiLinear } from 'react-icons/si'; import { SiLinear } from 'react-icons/si';
import { schema } from './schema'; import { schema } from './schema';
import { WidgetView } from './view'; import { WidgetView } from './view';
import { Edit } from './edit';
// https://linear.app/zeronorth/issue/VOY-93/save-a-new-cp-definition // https://linear.app/zeronorth/issue/VOY-93/save-a-new-cp-definition
@@ -23,6 +24,7 @@ const widget: Widget<typeof schema> = {
}, },
schema, schema,
component: WidgetView, component: WidgetView,
edit: Edit,
}; };
export default widget; export default widget;

View File

@@ -0,0 +1,31 @@
import { useCallback, useState } from 'react';
import { Props } from './schema';
import { Button, Form } from '@refocus/ui';
type EditorProps = {
value?: Props;
save: (data: Props) => void;
};
const Edit: React.FC<EditorProps> = ({ value, save }) => {
const [id, setId] = useState(value?.id || '');
const handleSave = useCallback(() => {
save({
id,
});
}, [id, save]);
return (
<Form>
<Form.Field label="Id">
<Form.Input value={id} onChange={(e) => setId(e.target.value)} />
</Form.Field>
<Form.Buttons>
<Button onClick={handleSave} title="Save" />
</Form.Buttons>
</Form>
);
};
export { Edit };

View File

@@ -2,6 +2,7 @@ import { Widget } from '@refocus/sdk';
import { SiLinear } from 'react-icons/si'; import { SiLinear } from 'react-icons/si';
import { schema } from './schema'; import { schema } from './schema';
import { WidgetView } from './view'; import { WidgetView } from './view';
import { Edit } from './edit';
// https://linear.app/zeronorth/issue/VOY-93/save-a-new-cp-definition // https://linear.app/zeronorth/issue/VOY-93/save-a-new-cp-definition
@@ -23,6 +24,7 @@ const widget: Widget<typeof schema> = {
}, },
schema, schema,
component: WidgetView, component: WidgetView,
edit: Edit,
}; };
export default widget; export default widget;

View File

@@ -0,0 +1,22 @@
import { useCallback } from 'react';
import { Button, Form } from '@refocus/ui';
type EditorProps = {
save: (data: {}) => void;
};
const Edit: React.FC<EditorProps> = ({ save }) => {
const handleSave = useCallback(() => {
save({});
}, [save]);
return (
<Form>
<Form.Buttons>
<Button onClick={handleSave} title="Save" />
</Form.Buttons>
</Form>
);
};
export { Edit };

View File

@@ -1,33 +1,18 @@
import { useLinearQuery, withLinear } from '@refocus/sdk'; import { Type } from '@sinclair/typebox';
import { Panel, Linear } from '@refocus/ui'; import { Widget } from '@refocus/sdk';
import { useEffect } from 'react'; import { SiLinear } from 'react-icons/si';
import { View } from './view';
import { Edit } from './edit';
const LinearMyIssues = withLinear(() => { const schema = Type.Object({});
const issues = useLinearQuery(async (client) => {
const me = await client.viewer;
const issues = await me.assignedIssues({
filter: {
completedAt: {
null: true,
},
},
});
return issues.nodes;
});
useEffect(() => { const linearMyIssuesWidget: Widget<typeof schema> = {
issues.fetch(); name: 'Linear My Issues',
}, []); id: 'linear.my-issues',
icon: <SiLinear />,
schema,
component: View,
edit: Edit,
};
return ( export default linearMyIssuesWidget;
<Panel title="My issue">
<ul>
{issues.data?.map((issue) => (
<Linear.Issue key={issue.id} issue={issue} />
))}
</ul>
</Panel>
);
}, Linear.NotLoggedIn);
export { LinearMyIssues };

View File

@@ -1,14 +0,0 @@
import { Type } from '@sinclair/typebox';
import { LinearMyIssues } from '.';
import { Widget } from '@refocus/sdk';
const schema = Type.Object({});
const linearMyIssuesWidget: Widget<typeof schema> = {
name: 'Linear My Issues',
id: 'linear.my-issues',
schema,
component: LinearMyIssues,
};
export default linearMyIssuesWidget;

View File

@@ -0,0 +1,46 @@
import {
useAutoUpdate,
useLinearQuery,
useName,
withLinear,
} from '@refocus/sdk';
import { Panel, Linear } from '@refocus/ui';
import { useEffect } from 'react';
const WidgetView = withLinear(() => {
const [, setName] = useName();
const { data, fetch } = useLinearQuery(async (client) => {
const me = await client.viewer;
const issues = await me.assignedIssues({
filter: {
completedAt: {
null: true,
},
},
});
return issues.nodes;
});
useEffect(() => {
setName('My issues');
}, [setName]);
useAutoUpdate(
{
action: fetch,
interval: 1000 * 60 * 5,
},
[],
);
return (
<Panel title="My issue">
<ul>
{data?.map((issue) => (
<Linear.Issue key={issue.id} issue={issue} />
))}
</ul>
</Panel>
);
}, Linear.NotLoggedIn);
export { WidgetView as View };

View File

@@ -0,0 +1,54 @@
import { Avatar, Popover, Typography, View } from '@refocus/ui';
import { useProfile } from '../../hooks';
import { styled } from 'styled-components';
import { useState } from 'react';
const Wrapper = styled(View)`
align-items: center;
gap: 0.5rem;
`;
const Trigger = styled(Popover.Trigger)`
display: inline-block;
color: ${({ theme }) => theme.colors.bg.highlight};
`;
const UserAvatar: React.FC<{ id: string }> = ({ id }) => {
const profile = useProfile(id);
const [open, setOpen] = useState(false);
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<Trigger>
<Avatar
size="sm"
url={profile?.profile?.image_192}
name={profile?.real_name || profile?.name}
/>
</Trigger>
<Popover.Portal>
<>
<Popover.Overlay />
<Popover.Content>
<Wrapper $fr $gap="sm">
<Avatar
url={profile?.profile?.image_192}
name={profile?.real_name || profile?.name}
/>
<View $fc>
<Typography>{profile?.real_name || profile?.name}</Typography>
<Typography variant="tiny">
{profile?.profile?.status_text}
</Typography>
</View>
</Wrapper>
</Popover.Content>
</>
</Popover.Portal>
</Popover>
</>
);
};
export { UserAvatar };

View File

@@ -0,0 +1,52 @@
import { Avatar, Popover, Typography, View } from '@refocus/ui';
import { useProfile } from '../../hooks';
import { styled } from 'styled-components';
import { useState } from 'react';
const Wrapper = styled(View)`
align-items: center;
gap: 0.5rem;
`;
const Trigger = styled(Popover.Trigger)`
display: inline-block;
color: ${({ theme }) => theme.colors.bg.highlight};
`;
const User: React.FC<{ id: string }> = ({ id }) => {
const profile = useProfile(id);
const [open, setOpen] = useState(false);
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<Trigger>
<Typography onClick={() => setOpen(true)}>
{profile?.real_name || profile?.name}
</Typography>
</Trigger>
<Popover.Portal>
<>
<Popover.Overlay />
<Popover.Content>
<Wrapper $fr $gap="sm">
<Avatar
url={profile?.profile?.image_192}
name={profile?.real_name || profile?.name}
/>
<View $fc>
<Typography>{profile?.real_name || profile?.name}</Typography>
<Typography variant="tiny">
{profile?.profile?.status_text}
</Typography>
</View>
</Wrapper>
</Popover.Content>
</>
</Popover.Portal>
</Popover>
</>
);
};
export { User };

View File

@@ -0,0 +1,69 @@
import { Fragment } from 'react';
import { User } from './elements/user';
import { Block, Renderable } from './types';
const unicodeToEmoji = (unicode: string) => {
const codePoints = unicode.split('-').map((u) => parseInt(u, 16));
return String.fromCodePoint(...codePoints);
};
const renderElement = (item: Renderable) => {
switch (item.type) {
case 'text':
return item.text.split('\n').flatMap((value, index, array) => {
const jsx = <Fragment key={index}>{value}</Fragment>;
if (index === array.length - 1) {
return [jsx];
}
return [jsx, <br key={'br' + index} />];
});
case 'link':
return (
<a href={item.url} target="_blank">
{item.text || item.url}
</a>
);
case 'user':
return <User id={item.user_id} />;
case 'emoji':
return unicodeToEmoji(item.unicode);
case 'rich_text_list':
return (
<ul>
{item.elements.map((elm, i) => (
<li key={i}>{renderElement(elm)}</li>
))}
</ul>
);
case 'rich_text':
case 'rich_text_section':
return (
<>
{item.elements.map((elm, i) => (
<Fragment key={i}>{renderElement(elm)}</Fragment>
))}
</>
);
default: {
console.log('Unknown element type', item);
return (
<>
Unknown element type: <pre>{JSON.stringify(item, null, 2)}</pre>
</>
);
}
}
};
const render = (blocks: Block[]) => {
return (
<>
{blocks.map((block, i) => (
<Fragment key={i}>{renderElement(block)}</Fragment>
))}
</>
);
};
export { render };

View File

@@ -0,0 +1,47 @@
type TextElement = {
type: 'text';
text: string;
};
type LinkElement = {
type: 'link';
url: string;
text: string;
};
type UserElement = {
type: 'user';
user_id: string;
};
type EmojiElement = {
type: 'emoji';
name: string;
unicode: string;
};
type RichTextElement = TextElement | LinkElement | UserElement | EmojiElement;
type RichTextSection = {
type: 'rich_text_section' | 'rich_text_list';
elements: RichTextElement[];
};
type RichText = {
type: 'rich_text';
elements: RichTextSection[];
};
type Renderable = RichTextElement | RichText | RichTextSection;
type Block = RichText;
export type {
TextElement,
LinkElement,
RichTextElement,
RichText,
UserElement,
EmojiElement,
Block,
Renderable,
};

View File

@@ -0,0 +1,31 @@
import { useCallback, useState } from 'react';
import { Props } from './schema';
import { Button, Form } from '@refocus/ui';
type EditorProps = {
value?: Props;
save: (data: Props) => void;
};
const Edit: React.FC<EditorProps> = ({ value, save }) => {
const [id, setId] = useState(value?.conversationId || '');
const handleSave = useCallback(() => {
save({
conversationId: id,
});
}, [id, save]);
return (
<Form>
<Form.Field label="Id">
<Form.Input value={id} onChange={(e) => setId(e.target.value)} />
</Form.Field>
<Form.Buttons>
<Button onClick={handleSave} title="Save" />
</Form.Buttons>
</Form>
);
};
export { Edit };

View File

@@ -2,6 +2,7 @@ import { Widget } from '@refocus/sdk';
import { schema } from './schema'; import { schema } from './schema';
import { WidgetView } from './view'; import { WidgetView } from './view';
import { SiSlack } from 'react-icons/si'; import { SiSlack } from 'react-icons/si';
import { Edit } from './edit';
// https://refocus.slack.com/archives/D05C97E7GB1I // https://refocus.slack.com/archives/D05C97E7GB1I
@@ -23,6 +24,7 @@ const widget: Widget<typeof schema> = {
}, },
schema, schema,
component: WidgetView, component: WidgetView,
edit: Edit,
}; };
export default widget; export default widget;

View File

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

View File

@@ -1,18 +1,94 @@
import { Chat } from '@refocus/ui'; import { Chat, Dialog, Typography, View } from '@refocus/ui';
import { useProfile } from '../../hooks'; import { useProfile } from '../../hooks';
import { Props } from './schema'; import { ConversationsHistoryResponse } from '@slack/web-api';
import { render } from '../../block/render';
import { useMemo } from 'react';
import { WidgetProvider, WidgetView } from '@refocus/sdk';
import { styled } from 'styled-components';
import { User } from '../../block/elements/user';
import { UserAvatar } from '../../block/elements/user-avatar';
const Message: React.FC<Props> = ({ text, userId }) => { type Message = Exclude<
const profile = useProfile(userId); ConversationsHistoryResponse['messages'],
undefined
>[0] & {
conversationId: string;
};
const LinkText = styled(Typography)`
color: ${({ theme }) => theme.colors.bg.highlight};
cursor: pointer;
`;
const Message: React.FC<Message> = ({
text,
blocks,
user,
reactions,
reply_count,
conversationId,
thread_ts,
ts,
}) => {
const profile = useProfile(user);
const threadData = useMemo(
() => ({ conversationId, ts: thread_ts }),
[conversationId, thread_ts],
);
const sendTime = new Date(parseInt(ts || '0', 10) * 1000);
return ( return (
<Chat.Message <Chat.Message
message={{ message={{
sender: { sender: {
name: profile?.real_name || profile?.name || 'Unknown', name: profile?.real_name || profile?.name || 'Unknown',
avatar: profile?.profile?.image_192,
}, },
text, text: (
timestamp: new Date(), <>
{blocks ? render(blocks as any) : text}
{(reactions || reply_count) && (
<View $bg="highlight100" $br $p="sm">
{reactions && (
<View $gap="sm" $fr>
{reactions.map((reaction) => (
<Typography variant="tiny" key={reaction.name} $gap="sm">
{reaction.name}
<View $fr>
{reaction.users?.map((user) => (
<UserAvatar id={user} />
))}
</View>
</Typography>
))}
</View>
)}
{reply_count && (
<Dialog>
<Dialog.Trigger>
<LinkText variant="tiny" $gap="sm">
{reply_count} replies
</LinkText>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<WidgetProvider
id="slack.conversation"
data={threadData}
>
<WidgetView />
</WidgetProvider>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
)}
</View>
)}
</>
),
timestamp: sendTime,
}} }}
/> />
); );

View File

@@ -7,14 +7,21 @@ import {
} from '@refocus/sdk'; } from '@refocus/sdk';
import styled from 'styled-components'; import styled from 'styled-components';
import { Props } from './schema'; import { Props } from './schema';
import { Message } from './message/view'; import { ConversationsHistoryResponse } from '@slack/web-api';
import { useState } from 'react'; import { useState } from 'react';
import { Chat, List, Slack, Typography, View } from '@refocus/ui'; import { Chat, Slack, Typography, View } from '@refocus/ui';
import { User } from '../block/elements/user';
import { Message } from './message/view';
type PostMessageOptions = { type PostMessageOptions = {
message: string; message: string;
}; };
type MessageType = Exclude<
ConversationsHistoryResponse['messages'],
undefined
>[0];
const MessageList = styled(View)` const MessageList = styled(View)`
transform: scaleY(-1); transform: scaleY(-1);
flex: 1; flex: 1;
@@ -31,16 +38,24 @@ const Wrapper = styled(View)`
max-height: 100%; max-height: 100%;
overflow: hidden; overflow: hidden;
`; `;
const WidgetView = withSlack<Props>(({ conversationId }) => { 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 } = useSlackQuery(async (client, props: Props) => {
if (props.ts) {
const response = await client.send('conversations.replies', {
channel: props.conversationId,
ts: props.ts,
});
return response.messages! as MessageType[];
} else {
const response = await client.send('conversations.history', { const response = await client.send('conversations.history', {
channel: props.conversationId, channel: props.conversationId,
limit: 5, limit: 5,
}); });
return response.messages!; 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', {
@@ -62,9 +77,10 @@ const WidgetView = withSlack<Props>(({ conversationId }) => {
useAutoUpdate( useAutoUpdate(
{ {
action: async () => { action: async () => {
await info.fetch({ conversationId }); await info.fetch({ conversationId, ts });
return fetch({ return fetch({
conversationId, conversationId,
ts,
}); });
}, },
interval: 1000 * 60, interval: 1000 * 60,
@@ -84,19 +100,28 @@ const WidgetView = withSlack<Props>(({ conversationId }) => {
} }
}, },
}, },
[conversationId], [conversationId, ts],
); );
return ( return (
<Wrapper $p="sm" $fc $gap="sm"> <Wrapper $p="sm" $fc $gap="sm">
<MessageList $gap="md" $fc> <MessageList $gap="md" $fc>
{data?.map((message) => ( {data?.map((message) => {
if ('subtype' in message && message.subtype === 'channel_join') {
return (
<Typography key={message.ts}>
<User id={message.user!} /> joined the channel
</Typography>
);
}
return (
<Message <Message
key={message.ts} key={message.ts}
text={message.text || '[No text|'} {...message}
userId={message.user} conversationId={conversationId}
/> />
))} );
})}
</MessageList> </MessageList>
<Chat.Compose <Chat.Compose
value={message} value={message}