Skip to content

Commit

Permalink
Merge pull request #624 from systemli/Add-Support-for-Bluesky
Browse files Browse the repository at this point in the history
✨Add Support for Bluesky
  • Loading branch information
0x46616c6b authored May 3, 2024
2 parents 4d54bfc + 3ccceae commit 7d86f60
Show file tree
Hide file tree
Showing 12 changed files with 415 additions and 1 deletion.
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

0 comments on commit 7d86f60

Please sign in to comment.