mirror of
https://github.com/morten-olsen/refocus.dev.git
synced 2026-02-08 00:46:25 +01:00
init
This commit is contained in:
2
packages/widgets/.gitignore
vendored
Normal file
2
packages/widgets/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
34
packages/widgets/package.json
Normal file
34
packages/widgets/package.json
Normal 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:^"
|
||||
}
|
||||
}
|
||||
12
packages/widgets/src/github/index.ts
Normal file
12
packages/widgets/src/github/index.ts
Normal 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 };
|
||||
28
packages/widgets/src/github/profile/editor.tsx
Normal file
28
packages/widgets/src/github/profile/editor.tsx
Normal 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 };
|
||||
30
packages/widgets/src/github/profile/index.tsx
Normal file
30
packages/widgets/src/github/profile/index.tsx
Normal 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 };
|
||||
14
packages/widgets/src/github/profile/index.widget.tsx
Normal file
14
packages/widgets/src/github/profile/index.widget.tsx
Normal 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;
|
||||
10
packages/widgets/src/github/profile/schema.ts
Normal file
10
packages/widgets/src/github/profile/schema.ts
Normal 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 };
|
||||
44
packages/widgets/src/github/pull-request-comments/edit.tsx
Normal file
44
packages/widgets/src/github/pull-request-comments/edit.tsx
Normal 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 };
|
||||
@@ -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;
|
||||
12
packages/widgets/src/github/pull-request-comments/schema.ts
Normal file
12
packages/widgets/src/github/pull-request-comments/schema.ts
Normal 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 };
|
||||
74
packages/widgets/src/github/pull-request-comments/view.tsx
Normal file
74
packages/widgets/src/github/pull-request-comments/view.tsx
Normal 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 };
|
||||
44
packages/widgets/src/github/pull-request/edit.tsx
Normal file
44
packages/widgets/src/github/pull-request/edit.tsx
Normal 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 };
|
||||
28
packages/widgets/src/github/pull-request/index.widget.tsx
Normal file
28
packages/widgets/src/github/pull-request/index.widget.tsx
Normal 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;
|
||||
12
packages/widgets/src/github/pull-request/schema.ts
Normal file
12
packages/widgets/src/github/pull-request/schema.ts
Normal 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 };
|
||||
36
packages/widgets/src/github/pull-request/view.tsx
Normal file
36
packages/widgets/src/github/pull-request/view.tsx
Normal 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 };
|
||||
8
packages/widgets/src/index.ts
Normal file
8
packages/widgets/src/index.ts
Normal 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 };
|
||||
12
packages/widgets/src/linear/index.ts
Normal file
12
packages/widgets/src/linear/index.ts
Normal 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 };
|
||||
28
packages/widgets/src/linear/issue-with-comments/index.tsx
Normal file
28
packages/widgets/src/linear/issue-with-comments/index.tsx
Normal 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;
|
||||
10
packages/widgets/src/linear/issue-with-comments/schema.ts
Normal file
10
packages/widgets/src/linear/issue-with-comments/schema.ts
Normal 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 };
|
||||
65
packages/widgets/src/linear/issue-with-comments/view.tsx
Normal file
65
packages/widgets/src/linear/issue-with-comments/view.tsx
Normal 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 };
|
||||
28
packages/widgets/src/linear/issue/index.tsx
Normal file
28
packages/widgets/src/linear/issue/index.tsx
Normal 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;
|
||||
10
packages/widgets/src/linear/issue/schema.ts
Normal file
10
packages/widgets/src/linear/issue/schema.ts
Normal 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 };
|
||||
46
packages/widgets/src/linear/issue/view.tsx
Normal file
46
packages/widgets/src/linear/issue/view.tsx
Normal 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 };
|
||||
33
packages/widgets/src/linear/my-issues/index.tsx
Normal file
33
packages/widgets/src/linear/my-issues/index.tsx
Normal 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 };
|
||||
14
packages/widgets/src/linear/my-issues/index.widget.tsx
Normal file
14
packages/widgets/src/linear/my-issues/index.widget.tsx
Normal 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;
|
||||
28
packages/widgets/src/slack/cache/profiles.ts
vendored
Normal file
28
packages/widgets/src/slack/cache/profiles.ts
vendored
Normal 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 };
|
||||
28
packages/widgets/src/slack/conversation/index.widget.tsx
Normal file
28
packages/widgets/src/slack/conversation/index.widget.tsx
Normal 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;
|
||||
11
packages/widgets/src/slack/conversation/message/schema.ts
Normal file
11
packages/widgets/src/slack/conversation/message/schema.ts
Normal 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 };
|
||||
21
packages/widgets/src/slack/conversation/message/view.tsx
Normal file
21
packages/widgets/src/slack/conversation/message/view.tsx
Normal 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 };
|
||||
11
packages/widgets/src/slack/conversation/schema.ts
Normal file
11
packages/widgets/src/slack/conversation/schema.ts
Normal 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 };
|
||||
94
packages/widgets/src/slack/conversation/view.tsx
Normal file
94
packages/widgets/src/slack/conversation/view.tsx
Normal 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 };
|
||||
23
packages/widgets/src/slack/hooks/index.ts
Normal file
23
packages/widgets/src/slack/hooks/index.ts
Normal 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 };
|
||||
6
packages/widgets/src/slack/index.ts
Normal file
6
packages/widgets/src/slack/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Widget } from '@refocus/sdk';
|
||||
import slackConversation from './conversation/index.widget';
|
||||
|
||||
const slack = [slackConversation] satisfies Widget<any>[];
|
||||
|
||||
export { slack };
|
||||
10
packages/widgets/tsconfig.esm.json
Normal file
10
packages/widgets/tsconfig.esm.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declarationDir": "./dist/esm/types",
|
||||
"outDir": "dist/esm"
|
||||
},
|
||||
"extends": "@refocus/config/esm",
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
10
packages/widgets/tsconfig.json
Normal file
10
packages/widgets/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declarationDir": "./dist/cjs/types",
|
||||
"outDir": "dist/cjs"
|
||||
},
|
||||
"extends": "@refocus/config/cjs",
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user