Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Add Support for Bluesky #624

Merged
merged 1 commit into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/api/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface Message {
createdAt: Date
telegramUrl?: string
mastodonUrl?: string
blueskyUrl?: string
geoInformation: string
attachments?: Attachment[]
}
Expand Down
31 changes: 31 additions & 0 deletions src/api/Ticker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface Ticker {
information: TickerInformation
mastodon: TickerMastodon
telegram: TickerTelegram
bluesky: TickerBluesky
location: TickerLocation
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -185,6 +199,21 @@ export function useTickerApi(token: string) {
}).then(response => response.json())
}

const putTickerBluesky = (data: TickerBlueskyFormData, ticker: Ticker): Promise<Response<TickerResponseData>> => {
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<Response<TickerResponseData>> => {
return fetch(`${ApiUrl}/admin/tickers/${ticker.id}/bluesky`, {
headers: headers,
method: 'delete',
}).then(response => response.json())
}

return {
deleteTicker,
deleteTickerUser,
Expand All @@ -199,5 +228,7 @@ export function useTickerApi(token: string) {
deleteTickerMastodon,
putTickerTelegram,
deleteTickerTelegram,
putTickerBluesky,
deleteTickerBluesky,
}
}
3 changes: 2 additions & 1 deletion src/components/message/MessageFooter.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -19,6 +19,7 @@ const MessageFooter: FC<Props> = ({ message }) => {
<Box>
<Icon icon={faTelegram} url={message.telegramUrl} />
<Icon icon={faMastodon} url={message.mastodonUrl} />
<Icon icon={faBluesky} url={message.blueskyUrl} />
</Box>
</Stack>
)
Expand Down
4 changes: 4 additions & 0 deletions src/components/message/MessageForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ const MessageForm: FC<Props> = ({ 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
}
Expand Down
103 changes: 103 additions & 0 deletions src/components/ticker/BlueskyCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={client}>
<MemoryRouter>
<AuthProvider>
<BlueskyCard ticker={ticker} />
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
)
}

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()
})
})
89 changes: 89 additions & 0 deletions src/components/ticker/BlueskyCard.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ ticker }) => {
const { token } = useAuth()
const { deleteTickerBluesky, putTickerBluesky } = useTickerApi(token)
const [open, setOpen] = useState<boolean>(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 = (
<Link href={'https://bsky.app/profile/' + bluesky.handle} rel="noreferrer" target="_blank">
{bluesky.handle}
</Link>
)

return (
<Card>
<CardContent>
<Stack alignItems="center" direction="row" justifyContent="space-between">
<Typography component="h5" variant="h5">
<FontAwesomeIcon icon={faBluesky} /> Bluesky
</Typography>
<Button onClick={() => setOpen(true)} size="small" startIcon={<FontAwesomeIcon icon={faGear} />}>
Configure
</Button>
</Stack>
</CardContent>
<Divider variant="middle" />
<CardContent>
{bluesky.connected ? (
<Box>
<Typography variant="body2">You are connected with Bluesky.</Typography>
<Typography variant="body2">Your Profile: {profileLink}</Typography>
</Box>
) : (
<Box>
<Typography variant="body2">You are not connected with Bluesky.</Typography>
<Typography variant="body2">New messages will not be published to your account and old messages can not be deleted anymore.</Typography>
</Box>
)}
</CardContent>
{bluesky.connected ? (
<CardActions>
{bluesky.active ? (
<Button onClick={handleToggle} size="small" startIcon={<FontAwesomeIcon icon={faPause} />}>
Disable
</Button>
) : (
<Button onClick={handleToggle} size="small" startIcon={<FontAwesomeIcon icon={faPlay} />}>
Enable
</Button>
)}
<Button onClick={handleDisconnect} size="small" startIcon={<FontAwesomeIcon icon={faTrash} />}>
Disconnect
</Button>
</CardActions>
) : null}
<BlueskyModalForm open={open} onClose={() => setOpen(false)} ticker={ticker} />
</Card>
)
}

export default BlueskyCard
86 changes: 86 additions & 0 deletions src/components/ticker/BlueskyForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={client}>
<MemoryRouter>
<AuthProvider>
<div>
<BlueskyForm callback={callback} ticker={ticker} />
<input name="Submit" type="submit" value="Submit" form="configureBluesky" />
</div>
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
)
}

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',
})
})
})
Loading
Loading