This commit is contained in:
Morten Olsen
2023-06-16 11:10:50 +02:00
commit bc0d501d98
163 changed files with 16458 additions and 0 deletions

2
packages/widgets/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/node_modules/
/dist/

View File

@@ -0,0 +1,34 @@
{
"devDependencies": {
"@refocus/config": "workspace:^",
"@types/react": "^18.0.37",
"typescript": "^5.0.4"
},
"exports": {
".": {
"import": {
"default": "./dist/esm/index.js",
"types": "./dist/esm/types/index.d.ts"
},
"require": {
"default": "./dist/cjs/index.js",
"types": "./dist/cjs/types/index.d.ts"
}
}
},
"files": [
"dist/**/*"
],
"main": "./dist/cjs/index.js",
"name": "@refocus/widgets",
"scripts": {
"build": "pnpm build:esm && pnpm build:cjs",
"build:cjs": "tsc -p tsconfig.json",
"build:esm": "tsc -p tsconfig.esm.json"
},
"types": "./dist/cjs/types/index.d.ts",
"dependencies": {
"@refocus/sdk": "workspace:^",
"@refocus/ui": "workspace:^"
}
}

View File

@@ -0,0 +1,12 @@
import { Widget } from '@refocus/sdk';
import githubProfileWidget from './profile/index.widget';
import pullRequest from './pull-request/index.widget';
import pullRequstComments from './pull-request-comments/index.widget';
const github = [
githubProfileWidget,
pullRequest,
pullRequstComments,
] satisfies Widget<any>[];
export { github };

View File

@@ -0,0 +1,28 @@
import { useCallback, useState } from 'react';
import { Props } from './schema';
type EditorProps = {
value?: Props;
save: (data: Props) => void;
};
const Editor: React.FC<EditorProps> = ({ value, save }) => {
const [data, setData] = useState<Props>(value || { username: '' });
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setData((data) => ({ ...data, [e.target.name]: e.target.value }));
}, []);
const handleSave = useCallback(() => {
save(data);
}, [data, save]);
return (
<div>
<input name="username" value={data.username} onChange={handleChange} />
<button onClick={handleSave}>Save</button>
</div>
);
};
export { Editor };

View File

@@ -0,0 +1,30 @@
import { useGithubQuery, withGithub } from '@refocus/sdk';
import { Github } from '@refocus/ui';
import { useEffect } from 'react';
type GithubProfileProps = {
username: string;
};
type QueryData = {
username: string;
};
const GithubProfile = withGithub<GithubProfileProps>(({ username }) => {
const user = useGithubQuery(async (client, params: QueryData) => {
const nextUser = await client.rest.users.getByUsername({
username: params.username,
});
return nextUser.data;
});
useEffect(() => {
user.fetch({ username });
}, [username]);
if (!user.data) return null;
return <Github.Profile profile={user.data} />;
}, Github.NotLoggedIn);
export { GithubProfile };

View File

@@ -0,0 +1,14 @@
import { GithubProfile } from '.';
import { Widget } from '@refocus/sdk';
import { schema } from './schema';
import { Editor } from './editor';
const githubProfileWidget: Widget<typeof schema> = {
name: 'Github Profile',
id: 'github.profile',
schema,
component: GithubProfile,
edit: Editor,
};
export default githubProfileWidget;

View File

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

View File

@@ -0,0 +1,44 @@
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 [owner, setOwner] = useState(value?.owner || '');
const [repo, setRepo] = useState(value?.repo || '');
const [pr, setPr] = useState(value?.pr || '');
const handleSave = useCallback(() => {
save({
owner,
repo,
pr: typeof pr === 'string' ? parseInt(pr, 10) : pr,
});
}, [owner, repo, pr, save]);
return (
<div>
<input
placeholder="Owner"
value={owner}
onChange={(e) => setOwner(e.target.value)}
/>
<input
placeholder="Repo"
value={repo}
onChange={(e) => setRepo(e.target.value)}
/>
<input
placeholder="PR"
value={pr}
onChange={(e) => setPr(e.target.value)}
/>
<button onClick={handleSave}>Save</button>
</div>
);
};
export { Edit };

View File

@@ -0,0 +1,28 @@
import { Widget } from '@refocus/sdk';
import { SiGithub } from 'react-icons/si';
import { schema } from './schema';
import { Edit } from './edit';
import { View } from './view';
const widget: Widget<typeof schema> = {
name: 'Github Pull Request Comments',
id: 'github.pull-request-comments',
icon: <SiGithub />,
description: 'Display the 5 latest comments on a Github pull request',
parseUrl: (url) => {
if (url.hostname !== 'github.com') {
return;
}
const pathParts = url.pathname.split('/').filter(Boolean);
const [owner, repo, type, pr] = pathParts.slice(0);
if (type !== 'pull' || !pr) {
return;
}
return { owner, repo, pr: parseInt(pr, 10) };
},
schema,
component: View,
edit: Edit,
};
export default widget;

View File

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

View File

@@ -0,0 +1,74 @@
import {
useAddWidgetNotification,
useAutoUpdate,
useGithubQuery,
withGithub,
} from '@refocus/sdk';
import { Chat, Github, List } from '@refocus/ui';
import { Props } from './schema';
import { View as PullRequest } from '../pull-request/view';
type QueryData = {
owner: string;
repo: string;
pr: number;
};
const View = withGithub<Props>(({ owner, repo, pr }) => {
const addNotification = useAddWidgetNotification();
const { data, fetch } = useGithubQuery(async (client, params: QueryData) => {
const response = await client.rest.pulls.listReviewComments({
owner: params.owner,
repo: params.repo,
pull_number: params.pr,
});
return response.data.slice(0, 5);
});
useAutoUpdate(
{
interval: 1000 * 60 * 5,
action: async () => fetch({ owner, repo, pr }),
callback: (next, prev) => {
if (prev && next) {
const previousIds = prev.map((comment) => comment.id);
const newComments = next.filter(
(comment) => !previousIds.includes(comment.id),
);
for (const comment of newComments) {
addNotification({
title: `New comments on PR #${pr} in ${owner}/${repo}`,
message: comment.body,
});
}
}
},
},
[owner, repo, pr, addNotification],
);
if (!data) {
return null;
}
return (
<List>
<PullRequest owner={owner} repo={repo} pr={pr} />
{data.map((comment) => (
<Chat.Message
message={{
sender: {
name: comment.user.login,
avatar: comment.user.avatar_url,
},
timestamp: new Date(comment.created_at),
text: comment.body,
}}
key={comment.id}
/>
))}
</List>
);
}, Github.NotLoggedIn);
export { View };

View File

@@ -0,0 +1,44 @@
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 [owner, setOwner] = useState(value?.owner || '');
const [repo, setRepo] = useState(value?.repo || '');
const [pr, setPr] = useState(value?.pr || '');
const handleSave = useCallback(() => {
save({
owner,
repo,
pr: typeof pr === 'string' ? parseInt(pr, 10) : pr,
});
}, [owner, repo, pr, save]);
return (
<div>
<input
placeholder="Owner"
value={owner}
onChange={(e) => setOwner(e.target.value)}
/>
<input
placeholder="Repo"
value={repo}
onChange={(e) => setRepo(e.target.value)}
/>
<input
placeholder="PR"
value={pr}
onChange={(e) => setPr(e.target.value)}
/>
<button onClick={handleSave}>Save</button>
</div>
);
};
export { Edit };

View File

@@ -0,0 +1,28 @@
import { Widget } from '@refocus/sdk';
import { SiGithub } from 'react-icons/si';
import { schema } from './schema';
import { Edit } from './edit';
import { View } from './view';
const widget: Widget<typeof schema> = {
name: 'Github Pull Request',
description: 'Display an info card for a Github pull request',
icon: <SiGithub />,
id: 'github.pull-request',
parseUrl: (url) => {
if (url.hostname !== 'github.com') {
return;
}
const pathParts = url.pathname.split('/').filter(Boolean);
const [owner, repo, type, pr] = pathParts.slice(0);
if (type !== 'pull' || !pr) {
return;
}
return { owner, repo, pr: parseInt(pr, 10) };
},
schema,
component: View,
edit: Edit,
};
export default widget;

View File

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

View File

@@ -0,0 +1,36 @@
import { useAutoUpdate, useGithubQuery, withGithub } from '@refocus/sdk';
import { Props } from './schema';
import { Github } from '@refocus/ui';
type QueryData = {
owner: string;
repo: string;
pr: number;
};
const View = withGithub<Props>(({ owner, repo, pr }) => {
const { data, fetch } = useGithubQuery(async (client, params: QueryData) => {
const response = await client.rest.pulls.get({
owner: params.owner,
repo: params.repo,
pull_number: params.pr,
});
return response.data;
});
useAutoUpdate(
{
interval: 1000 * 60 * 5,
action: async () => fetch({ owner, repo, pr }),
},
[owner, repo, pr],
);
if (!data) {
return null;
}
return <Github.PullRequest pullRequest={data} />;
}, Github.NotLoggedIn);
export { View };

View File

@@ -0,0 +1,8 @@
import { Widget } from '@refocus/sdk';
import { github } from './github';
import { linear } from './linear';
import { slack } from './slack';
const widgets = [...linear, ...github, ...slack] satisfies Widget<any>[];
export { widgets };

View File

@@ -0,0 +1,12 @@
import { Widget } from '@refocus/sdk';
import linearMyIssuesWidget from './my-issues/index.widget';
import linearIssue from './issue';
import linearIssueWithComments from './issue-with-comments';
const linear = [
linearMyIssuesWidget,
linearIssue,
linearIssueWithComments,
] satisfies Widget<any>[];
export { linear };

View File

@@ -0,0 +1,28 @@
import { Widget } from '@refocus/sdk';
import { SiLinear } from 'react-icons/si';
import { schema } from './schema';
import { WidgetView } from './view';
// https://linear.app/zeronorth/issue/VOY-93/save-a-new-cp-definition
const widget: Widget<typeof schema> = {
name: 'Linear Issue Comments',
description: 'Display the 5 latest comments on a Linear issue',
icon: <SiLinear />,
id: 'linear.issue-comments',
parseUrl: (url) => {
if (url.hostname !== 'linear.app') {
return;
}
const pathParts = url.pathname.split('/').filter(Boolean);
const [, type, id] = pathParts.slice(0);
if (type !== 'issue' || !id) {
return;
}
return { id };
},
schema,
component: WidgetView,
};
export default widget;

View File

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

View File

@@ -0,0 +1,65 @@
import {
useAddWidgetNotification,
useAutoUpdate,
useLinearQuery,
withLinear,
} from '@refocus/sdk';
import { Chat, Linear, List, View } from '@refocus/ui';
import { WidgetView as LinearIssueSummery } from '../issue/view';
type LinearIssueProps = {
id: string;
};
const WidgetView = withLinear<LinearIssueProps>(({ id }) => {
const addNotification = useAddWidgetNotification();
const { data, fetch } = useLinearQuery(async (client) => {
const issue = await client.issue(id);
const comments = await issue.comments();
return comments.nodes;
});
useAutoUpdate(
{
action: fetch,
interval: 1000 * 60 * 1,
callback: (next, prev) => {
if (!next || !prev) {
return;
}
const prevIds = prev.map((c) => c.id);
const newMessages = next.filter((c) => !prevIds.includes(c.id));
for (const message of newMessages) {
addNotification({
title: `New message on ${id}`,
message: message.body.substring(0, 100),
});
}
},
},
[id],
);
return (
<>
<LinearIssueSummery id={id} />
<List>
{data?.length === 0 && <View>No comments</View>}
{data?.map((comment) => (
<Chat.Message
message={{
sender: {
name: '',
},
timestamp: comment.createdAt,
text: comment.body,
}}
/>
))}
</List>
</>
);
}, Linear.NotLoggedIn);
export { WidgetView };

View File

@@ -0,0 +1,28 @@
import { Widget } from '@refocus/sdk';
import { SiLinear } from 'react-icons/si';
import { schema } from './schema';
import { WidgetView } from './view';
// https://linear.app/zeronorth/issue/VOY-93/save-a-new-cp-definition
const widget: Widget<typeof schema> = {
name: 'Linear Issue',
description: 'Display an info card for a Linear issue',
icon: <SiLinear />,
id: 'linear.issue',
parseUrl: (url) => {
if (url.hostname !== 'linear.app') {
return;
}
const pathParts = url.pathname.split('/').filter(Boolean);
const [, type, id] = pathParts.slice(0);
if (type !== 'issue' || !id) {
return;
}
return { id };
},
schema,
component: WidgetView,
};
export default widget;

View File

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

View File

@@ -0,0 +1,46 @@
import { useAutoUpdate, useLinearQuery, withLinear } from '@refocus/sdk';
import { Avatar, Card, Linear, Typography, View } from '@refocus/ui';
type LinearIssueProps = {
id: string;
};
const WidgetView = withLinear<LinearIssueProps>(({ id }) => {
const { data, fetch } = useLinearQuery(async (client) => {
const issue = await client.issue(id);
const assignee = await issue.assignee;
const creator = await issue.creator;
return {
issue,
assignee,
creator,
};
});
useAutoUpdate(
{
action: fetch,
interval: 1000 * 60 * 5,
},
[id],
);
return (
<Card $fr $gap="sm" $p="md">
<View>
<Typography variant="title">{data?.issue?.title}</Typography>
<Typography variant="tiny">
{data?.issue.description?.substring(0, 100)}
</Typography>
</View>
{data?.assignee && (
<Avatar url={data?.assignee?.avatarUrl} decal="Assigned" />
)}
{data?.creator && (
<Avatar url={data?.creator?.avatarUrl} decal="Creator" />
)}
</Card>
);
}, Linear.NotLoggedIn);
export { WidgetView };

View File

@@ -0,0 +1,33 @@
import { useLinearQuery, withLinear } from '@refocus/sdk';
import { Panel, Linear } from '@refocus/ui';
import { useEffect } from 'react';
const LinearMyIssues = withLinear(() => {
const issues = useLinearQuery(async (client) => {
const me = await client.viewer;
const issues = await me.assignedIssues({
filter: {
completedAt: {
null: true,
},
},
});
return issues.nodes;
});
useEffect(() => {
issues.fetch();
}, []);
return (
<Panel title="My issue">
<ul>
{issues.data?.map((issue) => (
<Linear.Issue key={issue.id} issue={issue} />
))}
</ul>
</Panel>
);
}, Linear.NotLoggedIn);
export { LinearMyIssues };

View File

@@ -0,0 +1,14 @@
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,28 @@
import { SlackClient, SlackTypes } from '@refocus/sdk';
type CacheEntry = {
promise: Promise<SlackTypes.Profile | undefined>;
};
class ProfileCache {
#entries: Record<string, CacheEntry> = {};
public get = async (id: string, client: SlackClient) => {
if (!this.#entries[id]) {
this.#entries[id] = {
promise: client
.send('users.info', {
user: id,
})
.then((data) => {
return data.user;
}),
};
}
return this.#entries[id].promise;
};
}
const profileCache = new ProfileCache();
export { profileCache };

View File

@@ -0,0 +1,28 @@
import { Widget } from '@refocus/sdk';
import { schema } from './schema';
import { WidgetView } from './view';
import { SiSlack } from 'react-icons/si';
// https://refocus.slack.com/archives/D05C97E7GB1I
const widget: Widget<typeof schema> = {
name: 'Slack Conversation',
description: 'Display the 5 latest messages of a Slack conversation',
id: 'slack.conversation',
icon: <SiSlack />,
parseUrl: (url) => {
if (url.hostname !== '0north.slack.com') {
return;
}
const pathParts = url.pathname.split('/').filter(Boolean);
const [type, id] = pathParts.slice(0);
if (type !== 'archives' || !id) {
return;
}
return { conversationId: id };
},
schema,
component: WidgetView,
};
export default widget;

View File

@@ -0,0 +1,11 @@
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

@@ -0,0 +1,21 @@
import { Chat } from '@refocus/ui';
import { useProfile } from '../../hooks';
import { Props } from './schema';
const Message: React.FC<Props> = ({ text, userId }) => {
const profile = useProfile(userId);
return (
<Chat.Message
message={{
sender: {
name: profile?.real_name || profile?.name || 'Unknown',
},
text,
timestamp: new Date(),
}}
/>
);
};
export { Message };

View File

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

View File

@@ -0,0 +1,94 @@
import {
useAddWidgetNotification,
useAutoUpdate,
useSlackQuery,
withSlack,
} from '@refocus/sdk';
import { Props } from './schema';
import { Message } from './message/view';
import { useState } from 'react';
import { Chat, List, Slack, Typography, View } from '@refocus/ui';
type PostMessageOptions = {
message: string;
};
const WidgetView = withSlack<Props>(({ conversationId }) => {
const addNotification = useAddWidgetNotification();
const [message, setMessage] = useState('');
const { fetch, data } = useSlackQuery(async (client, props: Props) => {
const response = await client.send('conversations.history', {
channel: props.conversationId,
limit: 5,
});
return response.messages!;
});
const info = useSlackQuery(async (client, props: Props) => {
const response = await client.send('conversations.info', {
channel: props.conversationId,
});
return response.channel!;
});
const { fetch: post } = useSlackQuery(
async (client, props: PostMessageOptions) => {
client.send('chat.postMessage', {
text: props.message,
channel: conversationId,
});
},
);
const update = useAutoUpdate(
{
action: async () => {
await info.fetch({ conversationId });
return fetch({
conversationId,
});
},
interval: 1000 * 60,
callback: (next, prev) => {
if (!prev || !next) {
return;
}
const prevIds = prev.map((message) => message.ts);
const newMessages = next.filter(
(message) => !prevIds.includes(message.ts),
);
for (let message of newMessages) {
addNotification({
title: 'New Message',
message: message.text || '[No Text]',
});
}
},
},
[conversationId],
);
return (
<View $p="md">
<button onClick={update}>Update</button>
<Typography variant="header">
{info.data?.name || 'Direct message'}
</Typography>
<Chat.Compose
value={message}
onValueChange={setMessage}
onSend={() => post({ message })}
/>
<List>
{data?.map((message) => (
<Message
key={message.ts}
text={message.text || '[No text|'}
userId={message.user}
/>
))}
</List>
</View>
);
}, Slack.NotLoggedIn);
export { WidgetView };

View File

@@ -0,0 +1,23 @@
import { SlackTypes, useSlack } from '@refocus/sdk';
import { useEffect, useState } from 'react';
import { profileCache } from '../cache/profiles';
const useProfile = (id?: string) => {
const { client } = useSlack();
const [profile, setProfile] = useState<SlackTypes.Profile>();
useEffect(() => {
const run = async () => {
if (!id || !client) {
return;
}
const nextProfile = await profileCache.get(id, client);
setProfile(nextProfile);
};
run();
}, [id, client]);
return profile;
};
export { useProfile };

View File

@@ -0,0 +1,6 @@
import { Widget } from '@refocus/sdk';
import slackConversation from './conversation/index.widget';
const slack = [slackConversation] satisfies Widget<any>[];
export { slack };

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"declarationDir": "./dist/esm/types",
"outDir": "dist/esm"
},
"extends": "@refocus/config/esm",
"include": [
"src/**/*"
]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"declarationDir": "./dist/cjs/types",
"outDir": "dist/cjs"
},
"extends": "@refocus/config/cjs",
"include": [
"src/**/*"
]
}