diff --git a/packages/pl-api/lib/client.ts b/packages/pl-api/lib/client.ts index 5ec98e0e0..344b72a07 100644 --- a/packages/pl-api/lib/client.ts +++ b/packages/pl-api/lib/client.ts @@ -251,6 +251,7 @@ import type { GetTrendingTags, } from './params/trends'; import type { PaginatedResponse } from './responses'; +import { ShoutMessage, shoutMessageSchema } from './entities/shout-message'; const GROUPED_TYPES = ['favourite', 'reblog', 'emoji_reaction', 'event_reminder', 'participation_accepted', 'participation_request']; @@ -285,6 +286,10 @@ class PlApiClient { unsubscribe: (stream: string, params?: { list?: string; tag?: string }) => void; close: () => void; }; + #shoutSocket?: { + message: (text: string) => void; + close: () => void; + } /** * @@ -2765,6 +2770,45 @@ class PlApiClient { }, }; + public readonly shoutbox = { + connect: (token: string, { onMessage, onMessages }: { + onMessages: (messages: Array) => void; + onMessage: (message: ShoutMessage) => void; + }) => { + if (this.#shoutSocket) return this.#shoutSocket; + + const path = buildFullPath('/socket/websocket', this.baseURL, { token, vsn: '2.0.0' }); + + const ws = new WebSocket(path); + + ws.onmessage = (event) => { + // @eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, __, ___, type, payload] = JSON.parse(event.data as string); + if (type === 'new_msg') { + const message = v.parse(payload, shoutMessageSchema); + onMessage(message); + } else if (type === 'messages') { + const messages = v.parse(filteredArray(shoutMessageSchema), payload.messages); + onMessages(messages); + } + }; + + ws.onopen = () => { + ws.send(JSON.stringify(['3', '3', 'chat:public', 'phx_join', {}])); + }; + + this.#shoutSocket = { + message: (text: string) => { + ws.send(JSON.stringify({ type: 'message', text })); + }, + close: () => { + ws.close(); + this.#shoutSocket = undefined; + }, + }; + }, + } + public readonly notifications = { /** * Get all notifications diff --git a/packages/pl-api/lib/entities/index.ts b/packages/pl-api/lib/entities/index.ts index 22d27c550..b4a897135 100644 --- a/packages/pl-api/lib/entities/index.ts +++ b/packages/pl-api/lib/entities/index.ts @@ -67,6 +67,7 @@ export * from './rule'; export * from './scheduled-status'; export * from './scrobble'; export * from './search'; +export * from './shout-message'; export * from './status'; export * from './status-edit'; export * from './status-source'; diff --git a/packages/pl-api/lib/entities/shout-message.ts b/packages/pl-api/lib/entities/shout-message.ts new file mode 100644 index 000000000..5de2c5a8b --- /dev/null +++ b/packages/pl-api/lib/entities/shout-message.ts @@ -0,0 +1,19 @@ +import * as v from 'valibot'; + +import { accountSchema } from './account'; + +/** + * @category Schemas + */ +const shoutMessageSchema = v.object({ + id: v.number(), + text: v.string(), + author: accountSchema, +}); + +/** + * @category Entity types + */ +type ShoutMessage = v.InferOutput; + +export { shoutMessageSchema, type ShoutMessage }; diff --git a/packages/pl-fe/src/actions/me.ts b/packages/pl-fe/src/actions/me.ts index a90874cfc..c7fbc84e8 100644 --- a/packages/pl-fe/src/actions/me.ts +++ b/packages/pl-fe/src/actions/me.ts @@ -131,5 +131,7 @@ export { fetchMeSuccess, fetchMeFail, patchMeSuccess, + getMeToken, + getMeUrl, type MeAction, }; diff --git a/packages/pl-fe/src/actions/shoutbox.ts b/packages/pl-fe/src/actions/shoutbox.ts new file mode 100644 index 000000000..36a527ed5 --- /dev/null +++ b/packages/pl-fe/src/actions/shoutbox.ts @@ -0,0 +1,59 @@ +import { verifyCredentials } from './auth'; +import { getMeToken, getMeUrl } from './me'; + +import type { PlApiClient, ShoutMessage } from 'pl-api'; +import type { AppDispatch, RootState } from 'pl-fe/store'; + +const SHOUTBOX_MESSAGE_IMPORT = 'SHOUTBOX_MESSAGE_IMPORT' as const; +const SHOUTBOX_MESSAGES_IMPORT = 'SHOUTBOX_MESSAGES_IMPORT' as const; +const SHOUTBOX_CONNECT = 'SHOUTBOX_CONNECT' as const; + +const importShoutboxMessages = (messages: ShoutMessage[]) => ({ + type: SHOUTBOX_MESSAGES_IMPORT, + messages, +}); + +const importShoutboxMessage = (message: ShoutMessage) => ({ + type: SHOUTBOX_MESSAGE_IMPORT, + message, +}); + +const connectShoutbox = (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const token = getMeToken(state); + const accountUrl = getMeUrl(state); + + if (!accountUrl) return; + + return dispatch(verifyCredentials(token, accountUrl)).then((account) => { + if (account.__meta.pleroma?.chat_token) { + const socket = state.auth.client.shoutbox.connect(account.__meta.pleroma?.chat_token, { + onMessage: (message) => dispatch(importShoutboxMessage(message)), + onMessages: (messages) => dispatch(importShoutboxMessages(messages)), + }); + + return dispatch({ + type: SHOUTBOX_CONNECT, + socket, + }); + } + }); +}; + +type ShoutboxAction = + | { + type: typeof SHOUTBOX_CONNECT; + socket: ReturnType<(InstanceType)['shoutbox']['connect']>; + } + | ReturnType + | ReturnType; + +export { + SHOUTBOX_MESSAGES_IMPORT, + SHOUTBOX_MESSAGE_IMPORT, + SHOUTBOX_CONNECT, + importShoutboxMessages, + importShoutboxMessage, + connectShoutbox, + type ShoutboxAction, +}; diff --git a/packages/pl-fe/src/features/ui/components/modals/alt-text-modal.tsx b/packages/pl-fe/src/features/ui/components/modals/alt-text-modal.tsx index d675bbe5a..c20ec07f3 100644 --- a/packages/pl-fe/src/features/ui/components/modals/alt-text-modal.tsx +++ b/packages/pl-fe/src/features/ui/components/modals/alt-text-modal.tsx @@ -158,7 +158,7 @@ const Preview: React.FC = ({ media, position: [x, y], onPositionCh {withFocalPoint && (
)['shoutbox']['connect']> | null; + messages: Array; +} + +const initialState: State = { + socket: null, + messages: [], +}; + +const shoutboxReducer = (state = initialState, action: ShoutboxAction) => { + switch (action.type) { + case SHOUTBOX_CONNECT: + return { ...state, socket: action.socket }; + case SHOUTBOX_MESSAGES_IMPORT: + return { ...state, messages: action.messages }; + case SHOUTBOX_MESSAGE_IMPORT: + return { ...state, messages: [...state.messages, action.message] }; + default: + return state; + } +}; + +export { shoutboxReducer as default };