From e2a6150444bc2179558f6fda4387008009668319 Mon Sep 17 00:00:00 2001 From: doobry Date: Sat, 3 Jan 2026 20:17:37 +0100 Subject: [PATCH] feat: add matrix integration support Signed-off-by: doobry --- src/api/Ticker.ts | 25 +++ src/components/ticker/MatrixCard.test.tsx | 144 +++++++++++++ src/components/ticker/MatrixCard.tsx | 208 +++++++++++++++++++ src/components/ticker/TickerCard.test.tsx | 22 +- src/components/ticker/TickerCard.tsx | 13 +- src/components/ticker/TickerIntegrations.tsx | 4 + src/views/TickerView.test.tsx | 1 + 7 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 src/components/ticker/MatrixCard.test.tsx create mode 100644 src/components/ticker/MatrixCard.tsx diff --git a/src/api/Ticker.ts b/src/api/Ticker.ts index 47e7df3c..7c37f24c 100644 --- a/src/api/Ticker.ts +++ b/src/api/Ticker.ts @@ -24,6 +24,7 @@ export interface TickerFormData { telegram: TickerTelegram bluesky: TickerBluesky signalGroup: TickerSignalGroup + matrix: TickerMatrix location: TickerLocation } @@ -39,6 +40,7 @@ export interface Ticker { telegram: TickerTelegram bluesky: TickerBluesky signalGroup: TickerSignalGroup + matrix: TickerMatrix location: TickerLocation } @@ -118,6 +120,17 @@ export interface TickerSignalGroupAdminFormData { number: string } +export interface TickerMatrix { + active: boolean + connected: boolean + roomID: string + roomName: string +} + +export interface TickerMatrixFormData { + active: boolean +} + export interface TickerLocation { lat: number lon: number @@ -249,3 +262,15 @@ export async function putTickerSignalGroupAdminApi(token: string, data: TickerSi body: JSON.stringify(data), }) } + +export async function putTickerMatrixApi(token: string, data: TickerMatrixFormData, ticker: Ticker): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${ticker.id}/matrix`, { + headers: apiHeaders(token), + method: 'put', + body: JSON.stringify(data), + }) +} + +export async function deleteTickerMatrixApi(token: string, ticker: Ticker): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${ticker.id}/matrix`, { headers: apiHeaders(token), method: 'delete' }) +} diff --git a/src/components/ticker/MatrixCard.test.tsx b/src/components/ticker/MatrixCard.test.tsx new file mode 100644 index 00000000..9fabdcd3 --- /dev/null +++ b/src/components/ticker/MatrixCard.test.tsx @@ -0,0 +1,144 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Ticker } from '../../api/Ticker' +import { renderWithProviders, setMockToken, userToken } from '../../tests/utils' +import MatrixCard from './MatrixCard' + +describe('MatrixCard', () => { + beforeEach(() => { + setMockToken(userToken) + fetchMock.resetMocks() + }) + + const ticker = ({ active, connected, roomName = '' }: { active: boolean; connected: boolean; roomName?: string }) => { + return { + id: 1, + matrix: { + active: active, + connected: connected, + roomName: roomName, + }, + } as Ticker + } + + const component = ({ ticker }: { ticker: Ticker }) => { + return + } + + it('should render the component', () => { + renderWithProviders(component({ ticker: ticker({ active: false, connected: false }) })) + + expect(screen.getByText('Matrix')).toBeInTheDocument() + expect(screen.getByText('You are not connected with Matrix.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument() + }) + + it('should render the component when connected and active', async () => { + renderWithProviders(component({ ticker: ticker({ active: true, connected: true, roomName: '#room:matrix.org' }) })) + + expect(screen.getByText('Matrix')).toBeInTheDocument() + expect(screen.getByText('You are connected with Matrix.')).toBeInTheDocument() + expect(screen.getByText('Your Room:')).toBeInTheDocument() + expect(screen.getByRole('link', { name: '#room:matrix.org' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Delete' })).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/matrix', { + body: JSON.stringify({ active: false }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + userToken, + }, + method: 'put', + }) + }) + + it('should handle add button click', async () => { + renderWithProviders(component({ ticker: ticker({ active: false, connected: false }) })) + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) + + await userEvent.click(screen.getByRole('button', { name: 'Add' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/v1/admin/tickers/1/matrix', { + body: JSON.stringify({ active: true }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + userToken, + }, + method: 'put', + }) + }) + + it('should handle delete with dialog', async () => { + renderWithProviders(component({ ticker: ticker({ active: true, connected: true, roomName: '#room:matrix.org' }) })) + + await userEvent.click(screen.getByRole('button', { name: 'Delete' })) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Delete Matrix integration')).toBeInTheDocument() + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) + + await userEvent.click(screen.getByTestId('dialog-delete')) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/v1/admin/tickers/1/matrix', { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + userToken, + }, + method: 'delete', + }) + }) + + it('should handle enable when inactive', async () => { + renderWithProviders(component({ ticker: ticker({ active: false, connected: true, roomName: '#room:matrix.org' }) })) + + expect(screen.getByRole('button', { name: 'Enable' })).toBeInTheDocument() + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) + + await userEvent.click(screen.getByRole('button', { name: 'Enable' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/v1/admin/tickers/1/matrix', { + body: JSON.stringify({ active: true }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + userToken, + }, + method: 'put', + }) + }) + + it('should fail when response fails', async () => { + renderWithProviders(component({ ticker: ticker({ active: true, connected: true, roomName: '#room:matrix.org' }) })) + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'error' })) + + await userEvent.click(screen.getByRole('button', { name: 'Disable' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('should fail when request fails', async () => { + renderWithProviders(component({ ticker: ticker({ active: true, connected: true, roomName: '#room:matrix.org' }) })) + + fetchMock.mockReject() + + await userEvent.click(screen.getByRole('button', { name: 'Disable' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/ticker/MatrixCard.tsx b/src/components/ticker/MatrixCard.tsx new file mode 100644 index 00000000..0e759cb0 --- /dev/null +++ b/src/components/ticker/MatrixCard.tsx @@ -0,0 +1,208 @@ +import { faAdd, faPause, faPlay, faTrash } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { + Box, + Button, + Card, + CardActions, + CardContent, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + Link, + Stack, + Typography, +} from '@mui/material' +import { useQueryClient } from '@tanstack/react-query' +import { FC, useState } from 'react' +import { handleApiCall } from '../../api/Api' +import { Ticker, deleteTickerMatrixApi, putTickerMatrixApi } from '../../api/Ticker' +import useAuth from '../../contexts/useAuth' +import useNotification from '../../contexts/useNotification' + +interface Props { + ticker: Ticker +} + +const MatrixCard: FC = ({ ticker }) => { + const { createNotification } = useNotification() + const { token } = useAuth() + const [dialogDeleteOpen, setDialogDeleteOpen] = useState(false) + const [submittingAdd, setSubmittingAdd] = useState(false) + const [submittingToggle, setSubmittingToggle] = useState(false) + const [submittingDelete, setSubmittingDelete] = useState(false) + const queryClient = useQueryClient() + + const matrix = ticker.matrix + + const handleAdd = () => { + setSubmittingAdd(true) + + handleApiCall(putTickerMatrixApi(token, { active: true }, ticker), { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + createNotification({ content: 'Matrix created successfully', severity: 'success' }) + setSubmittingAdd(false) + }, + onError: () => { + createNotification({ content: 'Failed to create Matrix', severity: 'error' }) + setSubmittingAdd(false) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + setSubmittingAdd(false) + }, + }) + } + + const handleToggle = () => { + setSubmittingToggle(true) + + handleApiCall(putTickerMatrixApi(token, { active: !matrix.active }, ticker), { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + createNotification({ content: `Matrix integration ${matrix.active ? 'disabled' : 'enabled'} successfully`, severity: 'success' }) + }, + onError: () => { + createNotification({ content: 'Failed to update Matrix integration', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, + }) + + setSubmittingToggle(false) + } + + const handleDelete = () => { + setSubmittingDelete(true) + deleteTickerMatrixApi(token, ticker) + .finally(() => { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + createNotification({ content: 'Matrix integration deleted successfully', severity: 'success' }) + }) + .catch(() => { + createNotification({ content: 'Failed to delete Matrix integration', severity: 'error' }) + }) + .finally(() => { + setDialogDeleteOpen(false) + setSubmittingDelete(false) + }) + } + + const roomLink = matrix.roomName && ( + + {matrix.roomName} + + ) + + return ( + + + + + Matrix + + {matrix.connected ? null : ( + + + {submittingAdd && ( + + )} + + )} + + + + + {matrix.connected ? ( + + You are connected with Matrix. + {roomLink && Your Room: {roomLink}} + + ) : ( + + You are not connected with Matrix. + New messages will not be published to your room and old messages can not be deleted anymore. + + )} + + {matrix.connected ? ( + + + {matrix.active ? ( + + ) : ( + + )} + {submittingToggle && ( + + )} + + + + ) : null} + + Delete Matrix integration + + Are you sure you want to delete the Matrix integration? This is irreversible. + + + + + + {submittingDelete && ( + + )} + + + + + ) +} + +export default MatrixCard diff --git a/src/components/ticker/TickerCard.test.tsx b/src/components/ticker/TickerCard.test.tsx index 9b35762e..252e922b 100644 --- a/src/components/ticker/TickerCard.test.tsx +++ b/src/components/ticker/TickerCard.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import { Ticker, TickerBluesky, TickerMastodon, TickerSignalGroup, TickerTelegram, TickerWebsite } from '../../api/Ticker' +import { Ticker, TickerBluesky, TickerMatrix, TickerMastodon, TickerSignalGroup, TickerTelegram, TickerWebsite } from '../../api/Ticker' import TickerCard from './TickerCard' describe('TickerCard', () => { @@ -11,6 +11,7 @@ describe('TickerCard', () => { telegram = { connected: false } as TickerTelegram, bluesky = { connected: false } as TickerBluesky, signalGroup = { connected: false } as TickerSignalGroup, + matrix = { connected: false } as TickerMatrix, }) => { return { id: 1, @@ -21,6 +22,7 @@ describe('TickerCard', () => { telegram: telegram, bluesky: bluesky, signalGroup: signalGroup, + matrix: matrix, } as unknown as Ticker } @@ -122,4 +124,22 @@ describe('TickerCard', () => { expect(screen.getByText('Integrations')).toBeInTheDocument() expect(screen.getAllByText('Signal Group')).toHaveLength(2) }) + + it('renders matrix integration', () => { + render( + + ) + + expect(screen.getByText('Integrations')).toBeInTheDocument() + expect(screen.getByText('Matrix')).toBeInTheDocument() + }) }) diff --git a/src/components/ticker/TickerCard.tsx b/src/components/ticker/TickerCard.tsx index 61ce0e49..7836fd60 100644 --- a/src/components/ticker/TickerCard.tsx +++ b/src/components/ticker/TickerCard.tsx @@ -17,7 +17,12 @@ const TickerCard: FC = ({ ticker }) => { const color = ticker.active ? 'primary' : 'warning' const hasIntegrations = - ticker.websites.length > 0 || ticker.mastodon.connected || ticker.telegram.connected || ticker.bluesky.connected || ticker.signalGroup.connected + ticker.websites.length > 0 || + ticker.mastodon.connected || + ticker.telegram.connected || + ticker.bluesky.connected || + ticker.signalGroup.connected || + ticker.matrix.connected return ( @@ -101,6 +106,12 @@ const Integrations = ({ ticker }: { ticker: Ticker }) => { value={} /> )} + {ticker.matrix.connected && ( + } + /> + )} ) } diff --git a/src/components/ticker/TickerIntegrations.tsx b/src/components/ticker/TickerIntegrations.tsx index 0c52aed0..77d7320d 100644 --- a/src/components/ticker/TickerIntegrations.tsx +++ b/src/components/ticker/TickerIntegrations.tsx @@ -3,6 +3,7 @@ import { FC } from 'react' import { Ticker } from '../../api/Ticker' import BlueskyCard from './BlueskyCard' import MastodonCard from './MastodonCard' +import MatrixCard from './MatrixCard' import SignalGroupCard from './SignalGroupCard' import TelegramCard from './TelegramCard' import WebsiteCard from './WebsiteCard' @@ -29,6 +30,9 @@ const TickerIntegrations: FC = ({ ticker }) => { + + + ) } diff --git a/src/views/TickerView.test.tsx b/src/views/TickerView.test.tsx index 81d162fc..04f54c5d 100644 --- a/src/views/TickerView.test.tsx +++ b/src/views/TickerView.test.tsx @@ -75,6 +75,7 @@ describe('TickerView', function () { telegram: {}, bluesky: {}, signalGroup: {}, + matrix: {}, location: {}, websites: [], },