diff --git a/src/api/Message.ts b/src/api/Message.ts index 3d44f09..bb4ac36 100644 --- a/src/api/Message.ts +++ b/src/api/Message.ts @@ -16,6 +16,7 @@ export interface Message { createdAt: Date telegramUrl?: string mastodonUrl?: string + blueskyUrl?: string geoInformation: string attachments?: Attachment[] } diff --git a/src/api/Ticker.ts b/src/api/Ticker.ts index d23fcad..6234464 100644 --- a/src/api/Ticker.ts +++ b/src/api/Ticker.ts @@ -24,6 +24,7 @@ export interface Ticker { information: TickerInformation mastodon: TickerMastodon telegram: TickerTelegram + bluesky: TickerBluesky location: TickerLocation } @@ -68,6 +69,19 @@ export interface TickerMastodonFormData { accessToken?: string } +export interface TickerBluesky { + active: boolean + connected: boolean + handle: string + appKey: string +} + +export interface TickerBlueskyFormData { + active: boolean + handle?: string + appKey?: string +} + export interface TickerLocation { lat: number lon: number @@ -185,6 +199,21 @@ export function useTickerApi(token: string) { }).then(response => response.json()) } + const putTickerBluesky = (data: TickerBlueskyFormData, ticker: Ticker): Promise> => { + return fetch(`${ApiUrl}/admin/tickers/${ticker.id}/bluesky`, { + headers: headers, + body: JSON.stringify(data), + method: 'put', + }).then(response => response.json()) + } + + const deleteTickerBluesky = (ticker: Ticker): Promise> => { + return fetch(`${ApiUrl}/admin/tickers/${ticker.id}/bluesky`, { + headers: headers, + method: 'delete', + }).then(response => response.json()) + } + return { deleteTicker, deleteTickerUser, @@ -199,5 +228,7 @@ export function useTickerApi(token: string) { deleteTickerMastodon, putTickerTelegram, deleteTickerTelegram, + putTickerBluesky, + deleteTickerBluesky, } } diff --git a/src/components/message/MessageFooter.tsx b/src/components/message/MessageFooter.tsx index c77d118..58b6c0f 100644 --- a/src/components/message/MessageFooter.tsx +++ b/src/components/message/MessageFooter.tsx @@ -1,6 +1,6 @@ import { FC } from 'react' import { IconProp } from '@fortawesome/fontawesome-svg-core' -import { faMastodon, faTelegram } from '@fortawesome/free-brands-svg-icons' +import { faBluesky, faMastodon, faTelegram } from '@fortawesome/free-brands-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { Box, Link, Stack, Typography, useTheme } from '@mui/material' import { Message } from '../../api/Message' @@ -19,6 +19,7 @@ const MessageFooter: FC = ({ message }) => { + ) diff --git a/src/components/message/MessageForm.tsx b/src/components/message/MessageForm.tsx index 71953eb..f1937f0 100644 --- a/src/components/message/MessageForm.tsx +++ b/src/components/message/MessageForm.tsx @@ -56,6 +56,10 @@ const MessageForm: FC = ({ ticker }) => { * @returns The maximum length of a message for the given ticker. */ const maxLength: number = (function (ticker: Ticker) { + if (ticker.bluesky.active) { + return 300 + } + if (ticker.mastodon.active) { return 500 } diff --git a/src/components/ticker/BlueskyCard.test.tsx b/src/components/ticker/BlueskyCard.test.tsx new file mode 100644 index 0000000..fe6df3a --- /dev/null +++ b/src/components/ticker/BlueskyCard.test.tsx @@ -0,0 +1,103 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import sign from 'jwt-encode' +import BlueskyCard from './BlueskyCard' +import { MemoryRouter } from 'react-router' +import { AuthProvider } from '../../contexts/AuthContext' +import { Ticker } from '../../api/Ticker' +import userEvent from '@testing-library/user-event' + +const token = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') + +describe('BlueSkyCard', () => { + beforeAll(() => { + localStorage.setItem('token', token) + }) + + const ticker = ({ active, connected, handle = '', appKey = '' }: { active: boolean; connected: boolean; handle?: string; appKey?: string }) => { + return { + id: 1, + bluesky: { + active: active, + connected: connected, + handle: handle, + appKey: appKey, + }, + } as Ticker + } + + beforeEach(() => { + fetchMock.resetMocks() + }) + + function setup(ticker: Ticker) { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return render( + + + + + + + + ) + } + + it('should render the component', () => { + setup(ticker({ active: false, connected: false })) + + expect(screen.getByText('Bluesky')).toBeInTheDocument() + expect(screen.getByText('You are not connected with Bluesky.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument() + }) + + it('should render the component when connected and active', async () => { + setup(ticker({ active: true, connected: true, handle: 'handle.bsky.social' })) + + expect(screen.getByText('Bluesky')).toBeInTheDocument() + expect(screen.getByText('You are connected with Bluesky.')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'handle.bsky.social' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Disable' })).toBeInTheDocument() + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) + + await userEvent.click(screen.getByRole('button', { name: 'Disable' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/v1/admin/tickers/1/bluesky', { + body: JSON.stringify({ active: false }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + token, + }, + method: 'put', + }) + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) + + await userEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/v1/admin/tickers/1/bluesky', { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + token, + }, + method: 'delete', + }) + + await userEvent.click(screen.getByRole('button', { name: 'Configure' })) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) +}) diff --git a/src/components/ticker/BlueskyCard.tsx b/src/components/ticker/BlueskyCard.tsx new file mode 100644 index 0000000..a49f074 --- /dev/null +++ b/src/components/ticker/BlueskyCard.tsx @@ -0,0 +1,89 @@ +import { Box, Button, Card, CardActions, CardContent, Divider, Link, Stack, Typography } from '@mui/material' +import { Ticker, useTickerApi } from '../../api/Ticker' +import useAuth from '../../contexts/useAuth' +import { FC, useCallback, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faBluesky } from '@fortawesome/free-brands-svg-icons' +import { faGear, faPause, faPlay, faTrash } from '@fortawesome/free-solid-svg-icons' +import BlueskyModalForm from './BlueskyModalForm' + +interface Props { + ticker: Ticker +} + +const BlueskyCard: FC = ({ ticker }) => { + const { token } = useAuth() + const { deleteTickerBluesky, putTickerBluesky } = useTickerApi(token) + const [open, setOpen] = useState(false) + + const queryClient = useQueryClient() + + const bluesky = ticker.bluesky + + const handleDisconnect = useCallback(() => { + deleteTickerBluesky(ticker).finally(() => { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + }) + }, [deleteTickerBluesky, queryClient, ticker]) + + const handleToggle = useCallback(() => { + putTickerBluesky({ active: !bluesky.active }, ticker).finally(() => { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + }) + }, [bluesky.active, putTickerBluesky, queryClient, ticker]) + + const profileLink = ( + + {bluesky.handle} + + ) + + return ( + + + + + Bluesky + + + + + + + {bluesky.connected ? ( + + You are connected with Bluesky. + Your Profile: {profileLink} + + ) : ( + + You are not connected with Bluesky. + New messages will not be published to your account and old messages can not be deleted anymore. + + )} + + {bluesky.connected ? ( + + {bluesky.active ? ( + + ) : ( + + )} + + + ) : null} + setOpen(false)} ticker={ticker} /> + + ) +} + +export default BlueskyCard diff --git a/src/components/ticker/BlueskyForm.test.tsx b/src/components/ticker/BlueskyForm.test.tsx new file mode 100644 index 0000000..1959d2d --- /dev/null +++ b/src/components/ticker/BlueskyForm.test.tsx @@ -0,0 +1,86 @@ +import sign from 'jwt-encode' +import { Ticker } from '../../api/Ticker' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { AuthProvider } from '../../contexts/AuthContext' +import BlueskyForm from './BlueskyForm' +import userEvent from '@testing-library/user-event' + +const token = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') + +describe('BlueskyForm', () => { + beforeAll(() => { + localStorage.setItem('token', token) + }) + + const ticker = ({ active, connected, handle = '', appKey = '' }: { active: boolean; connected: boolean; handle?: string; appKey?: string }) => { + return { + id: 1, + bluesky: { + active: active, + connected: connected, + handle: handle, + appKey: appKey, + }, + } as Ticker + } + + const callback = vi.fn() + + beforeEach(() => { + fetchMock.resetMocks() + }) + + function setup(ticker: Ticker) { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return render( + + + +
+ + +
+
+
+
+ ) + } + + it('should render the component', async () => { + setup(ticker({ active: false, connected: false })) + + expect(screen.getByText('You need to create a application password in Bluesky.')).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'Active' })).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Handle' })).toBeInTheDocument() + expect(screen.getByLabelText('Application Password *')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument() + + await userEvent.click(screen.getByRole('checkbox', { name: 'Active' })) + await userEvent.type(screen.getByRole('textbox', { name: 'Handle' }), 'handle.bsky.social') + await userEvent.type(screen.getByLabelText('Application Password *'), 'password') + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })) + + expect(callback).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/v1/admin/tickers/1/bluesky', { + body: '{"active":true,"handle":"handle.bsky.social","appKey":"password"}', + headers: { + Accept: 'application/json', + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json', + }, + method: 'put', + }) + }) +}) diff --git a/src/components/ticker/BlueskyForm.tsx b/src/components/ticker/BlueskyForm.tsx new file mode 100644 index 0000000..d22e65e --- /dev/null +++ b/src/components/ticker/BlueskyForm.tsx @@ -0,0 +1,73 @@ +import { FC } from 'react' +import { Ticker, TickerBlueskyFormData, useTickerApi } from '../../api/Ticker' +import useAuth from '../../contexts/useAuth' +import { useForm } from 'react-hook-form' +import { useQueryClient } from '@tanstack/react-query' +import { Alert, Checkbox, FormControlLabel, FormGroup, Grid, TextField, Typography } from '@mui/material' + +interface Props { + callback: () => void + ticker: Ticker +} + +const BlueskyForm: FC = ({ callback, ticker }) => { + const bluesky = ticker.bluesky + const { token } = useAuth() + const { putTickerBluesky } = useTickerApi(token) + const { + formState: { errors }, + handleSubmit, + register, + setError, + } = useForm({ + defaultValues: { + active: bluesky.active, + handle: bluesky.handle, + appKey: bluesky.appKey, + }, + }) + const queryClient = useQueryClient() + + const onSubmit = handleSubmit(data => { + putTickerBluesky(data, ticker).then(response => { + if (response.status == 'error') { + setError('root.authenticationFailed', { message: 'Authentication failed' }) + } else { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + callback() + } + }) + }) + + return ( +
+ + + You need to create a application password in Bluesky. + + {errors.root?.authenticationFailed && ( + + {errors.root.authenticationFailed.message} + + )} + + + } label="Active" /> + + + + + + + + + + + + + +
+ ) +} + +export default BlueskyForm diff --git a/src/components/ticker/BlueskyModalForm.tsx b/src/components/ticker/BlueskyModalForm.tsx new file mode 100644 index 0000000..8142774 --- /dev/null +++ b/src/components/ticker/BlueskyModalForm.tsx @@ -0,0 +1,20 @@ +import { FC } from 'react' +import { Ticker } from '../../api/Ticker' +import Modal from '../common/Modal' +import BlueskyForm from './BlueskyForm' + +interface Props { + onClose: () => void + open: boolean + ticker: Ticker +} + +const BlueskyModalForm: FC = ({ onClose, open, ticker }) => { + return ( + + + + ) +} + +export default BlueskyModalForm diff --git a/src/components/ticker/TickerCard.tsx b/src/components/ticker/TickerCard.tsx index 6efc7b6..ade5bff 100644 --- a/src/components/ticker/TickerCard.tsx +++ b/src/components/ticker/TickerCard.tsx @@ -38,6 +38,7 @@ const TickerCard: FC = ({ ticker }) => { + diff --git a/src/components/ticker/TickerSocialConnections.tsx b/src/components/ticker/TickerSocialConnections.tsx index 79e01bc..77aafd1 100644 --- a/src/components/ticker/TickerSocialConnections.tsx +++ b/src/components/ticker/TickerSocialConnections.tsx @@ -3,6 +3,7 @@ import { FC } from 'react' import { Ticker } from '../../api/Ticker' import MastodonCard from './MastodonCard' import TelegramCard from './TelegramCard' +import BlueskyCard from './BlueskyCard' interface Props { ticker: Ticker @@ -17,6 +18,9 @@ const TickerSocialConnections: FC = ({ ticker }) => { + + + ) } diff --git a/src/views/TickerView.test.tsx b/src/views/TickerView.test.tsx index e6aa162..eab1ff4 100644 --- a/src/views/TickerView.test.tsx +++ b/src/views/TickerView.test.tsx @@ -31,6 +31,7 @@ describe('TickerView', function () { mastodon: {}, twitter: {}, telegram: {}, + bluesky: {}, location: {}, }, },