pl-api: basic support for pleroma shoutbox

Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-03-28 21:12:42 +01:00
parent 42c5e6a151
commit e211dad0be
8 changed files with 156 additions and 1 deletions

View File

@ -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<ShoutMessage>) => 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

View File

@ -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';

View File

@ -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<typeof shoutMessageSchema>;
export { shoutMessageSchema, type ShoutMessage };

View File

@ -131,5 +131,7 @@ export {
fetchMeSuccess,
fetchMeFail,
patchMeSuccess,
getMeToken,
getMeUrl,
type MeAction,
};

View File

@ -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<typeof PlApiClient>)['shoutbox']['connect']>;
}
| ReturnType<typeof importShoutboxMessages>
| ReturnType<typeof importShoutboxMessage>;
export {
SHOUTBOX_MESSAGES_IMPORT,
SHOUTBOX_MESSAGE_IMPORT,
SHOUTBOX_CONNECT,
importShoutboxMessages,
importShoutboxMessage,
connectShoutbox,
type ShoutboxAction,
};

View File

@ -158,7 +158,7 @@ const Preview: React.FC<PreviewProps> = ({ media, position: [x, y], onPositionCh
</div>
{withFocalPoint && (
<div
className='pointer-events-none absolute h-24 w-24 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white'
className='pointer-events-none absolute size-24 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white'
style={{
top: `${y * 100}%`,
left: `${x * 100}%`,

View File

@ -27,6 +27,7 @@ import plfe from './pl-fe';
import polls from './polls';
import push_notifications from './push-notifications';
import security from './security';
import shoutbox from './shoutbox';
import status_lists from './status-lists';
import statuses from './statuses';
import timelines from './timelines';
@ -56,6 +57,7 @@ const reducers = {
polls,
push_notifications,
security,
shoutbox,
status_lists,
statuses,
timelines,

View File

@ -0,0 +1,28 @@
import { SHOUTBOX_CONNECT, SHOUTBOX_MESSAGES_IMPORT, SHOUTBOX_MESSAGE_IMPORT, type ShoutboxAction } from 'pl-fe/actions/shoutbox';
import type { PlApiClient, ShoutMessage } from 'pl-api';
interface State {
socket: ReturnType<(InstanceType<typeof PlApiClient>)['shoutbox']['connect']> | null;
messages: Array<ShoutMessage>;
}
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 };