Add real time messaging

This commit is contained in:
Aslan 2026-01-11 14:17:34 -05:00
parent 0163eab540
commit 9153ba841d
61 changed files with 882 additions and 230 deletions

View file

@ -1,6 +1,6 @@
{
"name": "pulsar-web",
"version": "0.3.0",
"version": "0.4.0",
"description": "",
"type": "module",
"scripts": {

View file

@ -1,15 +1,17 @@
import { Route, Router } from "@solidjs/router";
import type { Component } from "solid-js";
import { MainView } from "./views/MainView";
import { SettingsView } from "./views/SettingsView";
import { StartView } from "./views/StartView";
import { AppView } from "./views/AppView";
import { LoginView } from "./views/LoginView";
import { RegisterView } from "./views/RegisterView";
const App: Component = () => {
return (
<Router>
<Route path="/" component={MainView} />
<Route path="/settings" component={SettingsView} />
<Route path="/" component={StartView} />
<Route path="/app" component={AppView} />
<Route path="/login" component={LoginView} />
<Route path="/register" component={RegisterView} />
</Router>
);
};

View file

@ -2,7 +2,8 @@ import { callApi, HTTP } from "../tools";
import {
IFetchLoginRequest,
IFetchLoginResponse,
IFetchRefreshResponse,
IFetchRefreshResponseError,
IFetchRefreshResponseSuccess,
} from "./types";
const fetchLoginApi = async (
@ -11,7 +12,9 @@ const fetchLoginApi = async (
return await callApi(HTTP.POST, `auth/login`, request, true);
};
const fetchRefreshApi = async (): Promise<IFetchRefreshResponse> => {
const fetchRefreshApi = async (): Promise<
IFetchRefreshResponseError | IFetchRefreshResponseSuccess
> => {
return await callApi(HTTP.GET, `auth/refresh`, undefined, true);
};

View file

@ -8,7 +8,11 @@ interface IFetchLoginResponse {
ownerId: string;
}
interface IFetchRefreshResponse {
interface IFetchRefreshResponseError {
error: string;
}
interface IFetchRefreshResponseSuccess {
id: string;
ownerId: string;
token: string;
@ -17,5 +21,6 @@ interface IFetchRefreshResponse {
export {
type IFetchLoginRequest,
type IFetchLoginResponse,
type IFetchRefreshResponse,
type IFetchRefreshResponseError,
type IFetchRefreshResponseSuccess,
};

View file

@ -8,6 +8,8 @@ import {
IUpdateChannelResponse,
IRemoveChannelRequest,
IRemoveChannelResponse,
IFetchChannelMessagesRequest,
IFetchChannelMessagesResponse,
} from "./types";
const fetchChannelApi = async (
@ -34,9 +36,16 @@ const removeChannelApi = async (
return await callApi(HTTP.DELETE, `channel/${request.id}`);
};
const fetchChannelMessagesApi = async (
request: IFetchChannelMessagesRequest,
): Promise<IFetchChannelMessagesResponse> => {
return await callApi(HTTP.GET, `channel/${request.id}/messages`);
};
export {
fetchChannelApi,
createChannelApi,
updateChannelApi,
removeChannelApi,
fetchChannelMessagesApi,
};

View file

@ -36,6 +36,23 @@ interface IRemoveChannelResponse {
communityId: string;
}
interface IFetchChannelMessagesRequest {
id: string;
}
interface IFetchChannelMessagesResponse {
id: string;
messages: IFetchChannelMessage[];
}
interface IFetchChannelMessage {
id: string;
text: string;
edited: boolean;
ownerId: string;
creationDate: number;
}
export {
type IFetchChannel,
type IFetchChannelRequest,
@ -46,4 +63,7 @@ export {
type IUpdateChannelResponse,
type IRemoveChannelResponse,
type IRemoveChannelRequest,
type IFetchChannelMessagesRequest,
type IFetchChannelMessagesResponse,
type IFetchChannelMessage,
};

2
src/api/message/index.ts Normal file
View file

@ -0,0 +1,2 @@
export * from "./message";
export * from "./types";

View file

@ -0,0 +1,42 @@
import { callApi, HTTP } from "../tools";
import {
IFetchMessageRequest,
IFetchMessageResponse,
ICreateMessageRequest,
ICreateMessageResponse,
IUpdateMessageRequest,
IUpdateMessageResponse,
IRemoveMessageRequest,
IRemoveMessageResponse,
} from "./types";
const fetchMessageApi = async (
request: IFetchMessageRequest,
): Promise<IFetchMessageResponse> => {
return await callApi(HTTP.GET, `message/${request.id}`);
};
const createMessageApi = async (
request: ICreateMessageRequest,
): Promise<ICreateMessageResponse> => {
return await callApi(HTTP.POST, `message`, request);
};
const updateMessageApi = async (
request: IUpdateMessageRequest,
): Promise<IUpdateMessageResponse> => {
return await callApi(HTTP.PATCH, `message/${request.id}`, request);
};
const removeMessageApi = async (
request: IRemoveMessageRequest,
): Promise<IRemoveMessageResponse> => {
return await callApi(HTTP.DELETE, `message/${request.id}`);
};
export {
fetchMessageApi,
createMessageApi,
updateMessageApi,
removeMessageApi,
};

51
src/api/message/types.ts Normal file
View file

@ -0,0 +1,51 @@
interface IFetchMessage {
id: string;
text: string;
editHistory: string[];
edited: boolean;
ownerId: string;
channelId: string;
creationDate: number;
}
interface IFetchMessageRequest {
id: string;
}
interface IFetchMessageResponse extends IFetchMessage {}
interface ICreateMessageRequest {
text: string;
channelId: string;
}
interface ICreateMessageResponse extends IFetchMessage {}
interface IUpdateMessageRequest {
id: string;
text: string;
}
interface IUpdateMessageResponse extends IFetchMessage {}
interface IRemoveMessageRequest {
id: string;
}
interface IRemoveMessageResponse {
id: string;
ownerId: string;
channelId: string;
}
export {
type IFetchMessage,
type IFetchMessageRequest,
type IFetchMessageResponse,
type ICreateMessageRequest,
type ICreateMessageResponse,
type IUpdateMessageRequest,
type IUpdateMessageResponse,
type IRemoveMessageResponse,
type IRemoveMessageRequest,
};

View file

@ -1,5 +1,6 @@
import type { Component } from "solid-js";
import { ICommunityBarProps } from "./types";
import { SettingsIcon } from "../icons";
const CommunityBar: Component<ICommunityBarProps> = (
props: ICommunityBarProps,
@ -8,9 +9,17 @@ const CommunityBar: Component<ICommunityBarProps> = (
<div
class={`absolute w-full top-0 z-10 bg-cover bg-top bg-no-repeat bg-[url('${props.avatar}')]`}
>
<div class="flex flex-col justify-center bg-stone-800/25 backdrop-blur-md h-16 w-full shadow-bar px-5">
<h2 class="text-sm font-bold">{props.name}</h2>
<p class="text-xs">{props.description}</p>
<div class="flex flex-row justify-between items-center bg-stone-800/25 backdrop-blur-md h-16 shadow-bar pl-5 pr-3">
<div class="flex flex-col justify-center">
<h2 class="text-sm font-bold">{props.name}</h2>
<p class="text-xs">{props.description}</p>
</div>
<div
class="bg-stone-950/25 cursor-pointer rounded-full w-10 h-10 p-2 hover:bg-stone-950/75"
onClick={props.onSettingsClick}
>
<SettingsIcon />
</div>
</div>
</div>
);

View file

@ -3,6 +3,7 @@ interface ICommunityBarProps {
name?: string;
description?: string;
avatar?: string;
onSettingsClick?: () => void;
}
export { type ICommunityBarProps };

View file

@ -5,14 +5,19 @@ import { CommunityActionTypes } from "../../store/community";
import { InviteActionTypes } from "../../store/invite";
const CommunityModal: Component<ICommunityModalProps> = (props) => {
const [getCommunityName, setCommunityName] = createSignal<string>("");
const [getInviteId, setInviteId] = createSignal<string>("");
const [getCommunityName, setCommunityName] = createSignal("");
const [getInviteId, setInviteId] = createSignal("");
const onCreateCommunity = () => {
const communityName = getCommunityName();
if (!communityName || communityName.trim().length < 1) {
return;
}
dispatch({
type: CommunityActionTypes.CREATE_COMMUNITY_START,
payload: {
name: getCommunityName(),
name: communityName,
},
});
@ -22,9 +27,14 @@ const CommunityModal: Component<ICommunityModalProps> = (props) => {
};
const onJoinCommunity = () => {
const inviteId = getInviteId();
if (!inviteId || inviteId.trim().length < 1) {
return;
}
dispatch({
type: InviteActionTypes.ACCEPT_INVITE_START,
payload: getInviteId(),
payload: inviteId,
});
setInviteId("");
@ -32,6 +42,12 @@ const CommunityModal: Component<ICommunityModalProps> = (props) => {
props.onClose?.();
};
const handleEnter = (e: KeyboardEvent, callback: () => void) => {
if (e.key === "Enter") {
callback();
}
};
const createCommunityHtml = () => (
<>
<h3 class="text-lg font-bold text-center mb-6">
@ -44,6 +60,7 @@ const CommunityModal: Component<ICommunityModalProps> = (props) => {
placeholder="Enter name of the new community"
value={getCommunityName()}
onInput={(e) => setCommunityName(e.currentTarget.value)}
onKeyDown={(e) => handleEnter(e, onCreateCommunity)}
/>
</label>
<button
@ -68,6 +85,7 @@ const CommunityModal: Component<ICommunityModalProps> = (props) => {
placeholder="Enter invite ID"
value={getInviteId()}
onInput={(e) => setInviteId(e.currentTarget.value)}
onKeyDown={(e) => handleEnter(e, onJoinCommunity)}
/>
</label>
<button

View file

@ -0,0 +1,26 @@
import type { Component } from "solid-js";
import { ICommunitySettingsModalProps } from "./types";
const CommunitySettingsModal: Component<ICommunitySettingsModalProps> = (
props,
) => {
return (
<div>
<dialog ref={props.dialogRef} class="modal bg-[#00000050]">
<div class="modal-box bg-stone-950 rounded-3xl">
<h3 class="text-lg font-bold text-center">
Community Settings
</h3>
<p class="py-4 text-center">Not implemented yet</p>
</div>
<form
onClick={props.onClose}
method="dialog"
class="modal-backdrop"
></form>
</dialog>
</div>
);
};
export default CommunitySettingsModal;

View file

@ -0,0 +1,2 @@
export * from "./CommunitySettingsModal";
export * from "./types";

View file

@ -0,0 +1,6 @@
interface ICommunitySettingsModalProps {
dialogRef?: (element: HTMLDialogElement) => void;
onClose?: () => void;
}
export { type ICommunitySettingsModalProps };

View file

@ -1,13 +1,31 @@
import type { Component } from "solid-js";
import { IMessageBarProps } from "./types";
const MessageBar: Component<IMessageBarProps> = (props: IMessageBarProps) => {
const handleEnter = (e: KeyboardEvent) => {
if (e.key === "Enter") {
props.onSend?.();
}
};
const MessageBar: Component = () => {
return (
<div class="absolute w-full bottom-0 p-4 z-10">
<div class="bg-stone-800/25 backdrop-blur-lg h-16 shadow-bar p-2 flex flex-row gap-2 rounded-full">
<label class="bg-stone-800/50 backdrop-blur-lg input w-full h-full rounded-full focus:border-none outline-none">
<input type="text" placeholder="Send a message..." />
<input
type="text"
placeholder="Send a message..."
value={props.text}
onInput={(e) =>
props.onChangeText?.(e.currentTarget.value)
}
onKeyDown={handleEnter}
/>
</label>
<button class="bg-stone-950/50 backdrop-blur-lg btn btn-neutral h-full rounded-full">
<button
class="bg-stone-950/50 backdrop-blur-lg btn btn-neutral h-full rounded-full"
onClick={props.onSend}
>
Send
</button>
</div>

View file

@ -1 +1,2 @@
export * from "./MessageBar";
export * from "./types";

View file

@ -0,0 +1,7 @@
interface IMessageBarProps {
text: string;
onChangeText?: (text: string) => void;
onSend?: () => void;
}
export { type IMessageBarProps };

View file

@ -12,6 +12,10 @@ const fetchLogin = async (username: string, password: string) => {
type: AuthActionTypes.FETCH_LOGIN_FINISH,
payload: data,
});
dispatch({
type: AuthActionTypes.FETCH_REFRESH_START,
});
};
const fetchRefresh = async () => {
@ -21,6 +25,11 @@ const fetchRefresh = async () => {
type: AuthActionTypes.FETCH_REFRESH_FINISH,
payload: data,
});
dispatch({
type: AuthActionTypes.SET_LOGGED_IN,
payload: "id" in data,
});
};
export { fetchLogin, fetchRefresh };

View file

@ -3,6 +3,7 @@ import {
createChannelApi,
updateChannelApi,
removeChannelApi,
fetchChannelMessagesApi,
} from "../../api/channel";
import { ChannelActionTypes } from "../../store/channel";
import { dispatch } from "../../store/state";
@ -58,4 +59,21 @@ const removeChannel = async (id: string) => {
});
};
export { fetchChannel, createChannel, updateChannel, removeChannel };
const fetchChannelMessages = async (id: string) => {
const data = await fetchChannelMessagesApi({
id: id,
});
dispatch({
type: ChannelActionTypes.FETCH_CHANNEL_MESSAGES_FINISH,
payload: data,
});
};
export {
fetchChannel,
createChannel,
updateChannel,
removeChannel,
fetchChannelMessages,
};

View file

@ -0,0 +1 @@
export * from "./message";

View file

@ -0,0 +1,56 @@
import {
fetchMessageApi,
createMessageApi,
updateMessageApi,
removeMessageApi,
} from "../../api/message";
import { MessageActionTypes } from "../../store/message";
import { dispatch } from "../../store/state";
const fetchMessage = async (id: string) => {
const data = await fetchMessageApi({
id: id,
});
dispatch({
type: MessageActionTypes.FETCH_MESSAGE_FINISH,
payload: data,
});
};
const createMessage = async (text: string, channelId: string) => {
const data = await createMessageApi({
text: text,
channelId: channelId,
});
dispatch({
type: MessageActionTypes.CREATE_MESSAGE_FINISH,
payload: data,
});
};
const updateMessage = async (id: string, text: string) => {
const data = await updateMessageApi({
id: id,
text: text,
});
dispatch({
type: MessageActionTypes.UPDATE_MESSAGE_FINISH,
payload: data,
});
};
const removeMessage = async (id: string) => {
const data = await removeMessageApi({
id: id,
});
dispatch({
type: MessageActionTypes.REMOVE_MESSAGE_FINISH,
payload: data,
});
};
export { fetchMessage, createMessage, updateMessage, removeMessage };

View file

@ -0,0 +1,6 @@
{
"schema": "ws",
"url": "localhost",
"port": 3012,
"path": "ws"
}

View file

@ -0,0 +1,2 @@
export * from "./websocket";
export * from "./types";

View file

@ -0,0 +1,46 @@
import { IFetchChannelMessage } from "../../api/channel";
enum SocketRequestTypes {
PING = "PING",
}
enum SocketMessageTypes {
NEW_ANNOUNCEMENT = "NEW_ANNOUNCEMENT",
NEW_MESSAGE = "NEW_MESSAGE",
NEW_CHANNEL = "NEW_CHANNEL",
}
type SocketRequest = {
type: SocketRequestTypes.PING;
};
type SocketMessage =
| {
type: SocketMessageTypes.NEW_ANNOUNCEMENT;
payload: {
title: string;
description: string;
};
}
| {
type: SocketMessageTypes.NEW_MESSAGE;
payload: {
channelId: string;
message: IFetchChannelMessage;
};
}
| {
type: SocketMessageTypes.NEW_CHANNEL;
payload: {
id: string;
communityId: string;
name: string;
};
};
export {
SocketRequestTypes,
SocketMessageTypes,
type SocketRequest,
type SocketMessage,
};

View file

@ -0,0 +1,57 @@
import { ChannelActionTypes } from "../../store/channel";
import { dispatch } from "../../store/state";
import config from "./config.json";
import { SocketMessage, SocketMessageTypes } from "./types";
let connection: WebSocket;
const connectWs = () => {
connection = new WebSocket(
`${config.schema}://${config.url}:${config.port}/${config.path}`,
);
connection.onclose = onCloseWs;
connection.onmessage = onMessageWs;
};
const onCloseWs = () => {
setTimeout(connectWs, 10000);
};
const onMessageWs = (event: MessageEvent) => {
const message = parseMessage(event.data);
if (message) {
handleMessage(message);
}
};
const parseMessage = (data: string): SocketMessage | null => {
const request = JSON.parse(data) as SocketMessage;
if (!request.type) {
return null;
}
return request;
};
const handleMessage = (message: SocketMessage) => {
switch (message.type) {
case SocketMessageTypes.NEW_MESSAGE: {
dispatch({
type: ChannelActionTypes.SET_CHANNEL_MESSAGE,
payload: {
id: message.payload.channelId,
message: message.payload.message,
},
});
}
case SocketMessageTypes.NEW_CHANNEL: {
}
case SocketMessageTypes.NEW_ANNOUNCEMENT: {
}
}
};
export { connection, connectWs };

View file

@ -6,6 +6,7 @@ import { ChannelActionTypes, ChannelAction } from "./channel";
import { RoleActionTypes, RoleAction } from "./role";
import { SessionActionTypes, SessionAction } from "./session";
import { InviteActionTypes, InviteAction } from "./invite";
import { MessageActionTypes, MessageAction } from "./message";
type ActionTypes =
| AppActionTypes
@ -15,7 +16,8 @@ type ActionTypes =
| ChannelActionTypes
| RoleActionTypes
| SessionActionTypes
| InviteActionTypes;
| InviteActionTypes
| MessageActionTypes;
type Action =
| AppAction
@ -25,6 +27,7 @@ type Action =
| ChannelAction
| RoleAction
| SessionAction
| InviteAction;
| InviteAction
| MessageAction;
export { type Action, type ActionTypes };

View file

@ -2,11 +2,16 @@ enum AppActionTypes {
SET_HOME_OPEN = "SET_HOME_OPEN",
SET_SETTINGS_OPEN = "SET_SETTINGS_OPEN",
SET_ADD_COMMUNITY_OPEN = "SET_ADD_COMMUNITY_OPEN",
SET_ADD_COMMUNITY_SETTINGS_OPEN = "SET_ADD_COMMUNITY_SETTINGS_OPEN",
}
type AppAction =
| { type: AppActionTypes.SET_HOME_OPEN; payload: boolean }
| { type: AppActionTypes.SET_SETTINGS_OPEN; payload: boolean }
| { type: AppActionTypes.SET_ADD_COMMUNITY_OPEN; payload: boolean };
| { type: AppActionTypes.SET_ADD_COMMUNITY_OPEN; payload: boolean }
| {
type: AppActionTypes.SET_ADD_COMMUNITY_SETTINGS_OPEN;
payload: boolean;
};
export { AppActionTypes, type AppAction };

View file

@ -15,6 +15,15 @@ function appReducer(_state: IAppState, action: AppAction) {
setState("app", "dialogsOpen", "addCommunityOpen", false);
setState("app", "dialogsOpen", "addCommunityOpen", action.payload);
break;
case AppActionTypes.SET_ADD_COMMUNITY_SETTINGS_OPEN:
setState("app", "dialogsOpen", "communitySettingsOpen", false);
setState(
"app",
"dialogsOpen",
"communitySettingsOpen",
action.payload,
);
break;
}
}

View file

@ -6,6 +6,7 @@ interface IAppState {
interface IDialogsOpen {
settingsOpen: boolean;
addCommunityOpen: boolean;
communitySettingsOpen: boolean;
}
export { type IAppState, type IDialogsOpen };

View file

@ -1,10 +1,12 @@
import {
IFetchLoginRequest,
IFetchLoginResponse,
IFetchRefreshResponse,
IFetchRefreshResponseError,
IFetchRefreshResponseSuccess,
} from "../../api/auth";
enum AuthActionTypes {
SET_LOGGED_IN = "SET_LOGGED_IN",
FETCH_LOGIN_START = "FETCH_LOGIN_START",
FETCH_LOGIN_FINISH = "FETCH_LOGIN_FINISH",
FETCH_REFRESH_START = "FETCH_REFRESH_START",
@ -12,6 +14,7 @@ enum AuthActionTypes {
}
type AuthAction =
| { type: AuthActionTypes.SET_LOGGED_IN; payload: boolean }
| { type: AuthActionTypes.FETCH_LOGIN_START; payload: IFetchLoginRequest }
| {
type: AuthActionTypes.FETCH_LOGIN_FINISH;
@ -20,7 +23,7 @@ type AuthAction =
| { type: AuthActionTypes.FETCH_REFRESH_START }
| {
type: AuthActionTypes.FETCH_REFRESH_FINISH;
payload: IFetchRefreshResponse;
payload: IFetchRefreshResponseError | IFetchRefreshResponseSuccess;
};
export { AuthActionTypes, type AuthAction };

View file

@ -5,6 +5,9 @@ import { IAuthState } from "./types";
function authReducer(_state: IAuthState, action: AuthAction) {
switch (action.type) {
case AuthActionTypes.SET_LOGGED_IN:
setState("auth", "loggedIn", action.payload);
break;
case AuthActionTypes.FETCH_LOGIN_START:
fetchLogin(action.payload.username, action.payload.password);
break;
@ -15,7 +18,9 @@ function authReducer(_state: IAuthState, action: AuthAction) {
fetchRefresh();
break;
case AuthActionTypes.FETCH_REFRESH_FINISH:
setState("auth", "session", action.payload);
if ("id" in action.payload) {
setState("auth", "session", action.payload);
}
break;
}
}

View file

@ -1,4 +1,5 @@
interface IAuthState {
loggedIn?: boolean;
session?: ISession;
}

View file

@ -5,12 +5,16 @@ import {
ICreateChannelResponse,
IUpdateChannelResponse,
IRemoveChannelResponse,
IFetchChannelMessagesResponse,
IFetchChannelMessage,
} from "../../api/channel";
import { IFetchCommunityChannel } from "../../api/community";
enum ChannelActionTypes {
SET_CHANNEL = "SET_CHANNEL",
SET_ACTIVE_CHANNEL = "SET_ACTIVE_CHANNEL",
SET_TEXT = "SET_TEXT",
SET_CHANNEL_MESSAGE = "SET_CHANNEL_MESSAGE",
FETCH_CHANNEL_START = "FETCH_CHANNEL_START",
FETCH_CHANNEL_FINISH = "FETCH_CHANNEL_FINISH",
CREATE_CHANNEL_START = "CREATE_CHANNEL_START",
@ -19,6 +23,8 @@ enum ChannelActionTypes {
UPDATE_CHANNEL_FINISH = "UPDATE_CHANNEL_FINISH",
REMOVE_CHANNEL_START = "REMOVE_CHANNEL_START",
REMOVE_CHANNEL_FINISH = "REMOVE_CHANNEL_FINISH",
FETCH_CHANNEL_MESSAGES_START = "FETCH_CHANNEL_MESSAGES_START",
FETCH_CHANNEL_MESSAGES_FINISH = "FETCH_CHANNEL_MESSAGES_FINISH",
}
type ChannelAction =
@ -30,6 +36,14 @@ type ChannelAction =
type: ChannelActionTypes.SET_ACTIVE_CHANNEL;
payload: string | undefined;
}
| {
type: ChannelActionTypes.SET_TEXT;
payload: { id: string; text: string };
}
| {
type: ChannelActionTypes.SET_CHANNEL_MESSAGE;
payload: { id: string; message: IFetchChannelMessage };
}
| { type: ChannelActionTypes.FETCH_CHANNEL_START; payload: string }
| {
type: ChannelActionTypes.FETCH_CHANNEL_FINISH;
@ -55,6 +69,11 @@ type ChannelAction =
| {
type: ChannelActionTypes.REMOVE_CHANNEL_FINISH;
payload: IRemoveChannelResponse;
}
| { type: ChannelActionTypes.FETCH_CHANNEL_MESSAGES_START; payload: string }
| {
type: ChannelActionTypes.FETCH_CHANNEL_MESSAGES_FINISH;
payload: IFetchChannelMessagesResponse;
};
export { ChannelActionTypes, type ChannelAction };

View file

@ -3,12 +3,13 @@ import {
createChannel,
updateChannel,
removeChannel,
fetchChannelMessages,
} from "../../services/channel";
import { setState } from "../state";
import { ChannelActionTypes, ChannelAction } from "./actions";
import { IChannelState } from "./types";
function channelReducer(state: IChannelState, action: ChannelAction) {
function channelReducer(_state: IChannelState, action: ChannelAction) {
switch (action.type) {
case ChannelActionTypes.SET_CHANNEL:
setState("channel", "channels", action.payload.id, action.payload);
@ -16,6 +17,25 @@ function channelReducer(state: IChannelState, action: ChannelAction) {
case ChannelActionTypes.SET_ACTIVE_CHANNEL:
setState("channel", "active", action.payload);
break;
case ChannelActionTypes.SET_TEXT:
setState(
"channel",
"channels",
action.payload.id,
"text",
action.payload.text,
);
break;
case ChannelActionTypes.SET_CHANNEL_MESSAGE:
setState(
"channel",
"channels",
action.payload.id,
"messages",
action.payload.message.id,
action.payload.message,
);
break;
case ChannelActionTypes.FETCH_CHANNEL_START:
fetchChannel(action.payload);
break;
@ -48,6 +68,25 @@ function channelReducer(state: IChannelState, action: ChannelAction) {
return copy;
});
break;
case ChannelActionTypes.FETCH_CHANNEL_MESSAGES_START:
fetchChannelMessages(action.payload);
break;
case ChannelActionTypes.FETCH_CHANNEL_MESSAGES_FINISH:
setState(
"channel",
"channels",
action.payload.id,
"messages",
(messages) => {
const newMessages = Object.fromEntries(
action.payload.messages.map((item) => [item.id, item]),
);
const copy = { ...messages, ...newMessages };
return copy;
},
);
break;
}
}

View file

@ -1,3 +1,5 @@
import { IMessage } from "../message";
interface IChannelState {
active?: string;
channels: Record<string, IChannel>;
@ -9,6 +11,8 @@ interface IChannel {
description?: string;
communityId?: string;
creationDate?: number;
text?: string;
messages?: Record<string, IMessage>;
}
export { type IChannelState, type IChannel };

View file

@ -12,7 +12,7 @@ import { setState } from "../state";
import { CommunityActionTypes, CommunityAction } from "./actions";
import { ICommunityState } from "./types";
function communityReducer(state: ICommunityState, action: CommunityAction) {
function communityReducer(_state: ICommunityState, action: CommunityAction) {
switch (action.type) {
case CommunityActionTypes.SET_COMMUNITY:
setState(

View file

@ -3,7 +3,7 @@ import { setState } from "../state";
import { InviteActionTypes, InviteAction } from "./actions";
import { IInviteState } from "./types";
function inviteReducer(state: IInviteState, action: InviteAction) {
function inviteReducer(_state: IInviteState, action: InviteAction) {
switch (action.type) {
case InviteActionTypes.SET_INVITE:
setState("invite", "invites", action.payload.id, action.payload);

View file

@ -0,0 +1,49 @@
import {
ICreateMessageRequest,
IUpdateMessageRequest,
IFetchMessageResponse,
ICreateMessageResponse,
IUpdateMessageResponse,
IRemoveMessageResponse,
} from "../../api/message";
enum MessageActionTypes {
FETCH_MESSAGE_START = "FETCH_MESSAGE_START",
FETCH_MESSAGE_FINISH = "FETCH_MESSAGE_FINISH",
CREATE_MESSAGE_START = "CREATE_MESSAGE_START",
CREATE_MESSAGE_FINISH = "CREATE_MESSAGE_FINISH",
UPDATE_MESSAGE_START = "UPDATE_MESSAGE_START",
UPDATE_MESSAGE_FINISH = "UPDATE_MESSAGE_FINISH",
REMOVE_MESSAGE_START = "REMOVE_MESSAGE_START",
REMOVE_MESSAGE_FINISH = "REMOVE_MESSAGE_FINISH",
}
type MessageAction =
| { type: MessageActionTypes.FETCH_MESSAGE_START; payload: string }
| {
type: MessageActionTypes.FETCH_MESSAGE_FINISH;
payload: IFetchMessageResponse;
}
| {
type: MessageActionTypes.CREATE_MESSAGE_START;
payload: ICreateMessageRequest;
}
| {
type: MessageActionTypes.CREATE_MESSAGE_FINISH;
payload: ICreateMessageResponse;
}
| {
type: MessageActionTypes.UPDATE_MESSAGE_START;
payload: IUpdateMessageRequest;
}
| {
type: MessageActionTypes.UPDATE_MESSAGE_FINISH;
payload: IUpdateMessageResponse;
}
| { type: MessageActionTypes.REMOVE_MESSAGE_START; payload: string }
| {
type: MessageActionTypes.REMOVE_MESSAGE_FINISH;
payload: IRemoveMessageResponse;
};
export { MessageActionTypes, type MessageAction };

View file

@ -0,0 +1,3 @@
export * from "./message";
export * from "./actions";
export * from "./types";

View file

@ -0,0 +1,59 @@
import {
fetchMessage,
createMessage,
updateMessage,
removeMessage,
} from "../../services/message";
import { setState } from "../state";
import { MessageActionTypes, MessageAction } from "./actions";
import { IMessageState } from "./types";
function messageReducer(_state: IMessageState, action: MessageAction) {
switch (action.type) {
case MessageActionTypes.FETCH_MESSAGE_START:
fetchMessage(action.payload);
break;
case MessageActionTypes.FETCH_MESSAGE_FINISH:
setState("message", "message", action.payload);
setState(
"channel",
"channels",
action.payload.channelId,
action.payload,
);
break;
case MessageActionTypes.CREATE_MESSAGE_START:
createMessage(action.payload.text, action.payload.channelId);
break;
case MessageActionTypes.CREATE_MESSAGE_FINISH:
setState("message", "message", action.payload);
setState(
"channel",
"channels",
action.payload.channelId,
"messages",
action.payload.id,
action.payload,
);
break;
case MessageActionTypes.UPDATE_MESSAGE_START:
updateMessage(action.payload.id, action.payload.text);
break;
case MessageActionTypes.UPDATE_MESSAGE_FINISH:
setState("message", "message", action.payload);
setState(
"channel",
"channels",
action.payload.channelId,
action.payload,
);
break;
case MessageActionTypes.REMOVE_MESSAGE_START:
removeMessage(action.payload);
break;
case MessageActionTypes.REMOVE_MESSAGE_FINISH:
break;
}
}
export { messageReducer };

View file

@ -0,0 +1,15 @@
interface IMessageState {
message?: IMessage;
}
interface IMessage {
id: string;
text: string;
editHistory?: string[];
edited: boolean;
ownerId: string;
channelId?: string;
creationDate: number;
}
export { type IMessageState, type IMessage };

View file

@ -8,6 +8,7 @@ import { ChannelAction, channelReducer } from "./channel";
import { RoleAction, roleReducer } from "./role";
import { SessionAction, sessionReducer } from "./session";
import { InviteAction, inviteReducer } from "./invite";
import { MessageAction, messageReducer } from "./message";
function reducer(state: IState, action: Action) {
appReducer(state.app, action as AppAction);
@ -18,6 +19,7 @@ function reducer(state: IState, action: Action) {
roleReducer(state.role, action as RoleAction);
sessionReducer(state.session, action as SessionAction);
inviteReducer(state.invite, action as InviteAction);
messageReducer(state.message, action as MessageAction);
}
export { reducer };

View file

@ -1,6 +1,7 @@
import { IFetchCommunityRole } from "../../api/community";
import {
ICreateRoleRequest,
IUpdateRoleRequest,
IFetchRoleResponse,
ICreateRoleResponse,
IUpdateRoleResponse,
@ -34,7 +35,7 @@ type RoleAction =
type: RoleActionTypes.CREATE_ROLE_FINISH;
payload: ICreateRoleResponse;
}
| { type: RoleActionTypes.UPDATE_ROLE_START; payload: string }
| { type: RoleActionTypes.UPDATE_ROLE_START; payload: IUpdateRoleRequest }
| {
type: RoleActionTypes.UPDATE_ROLE_FINISH;
payload: IUpdateRoleResponse;

View file

@ -8,7 +8,7 @@ import { setState } from "../state";
import { RoleActionTypes, RoleAction } from "./actions";
import { IRoleState } from "./types";
function roleReducer(state: IRoleState, action: RoleAction) {
function roleReducer(_state: IRoleState, action: RoleAction) {
switch (action.type) {
case RoleActionTypes.SET_ROLE:
setState("role", "roles", action.payload.id, action.payload);
@ -26,7 +26,7 @@ function roleReducer(state: IRoleState, action: RoleAction) {
setState("role", "roles", action.payload.id, action.payload);
break;
case RoleActionTypes.UPDATE_ROLE_START:
updateRole(action.payload);
updateRole(action.payload.id, action.payload.name);
break;
case RoleActionTypes.UPDATE_ROLE_FINISH:
setState("role", "roles", action.payload.id, action.payload);

View file

@ -3,7 +3,7 @@ import { setState } from "../state";
import { SessionActionTypes, SessionAction } from "./actions";
import { ISessionState } from "./types";
function sessionReducer(state: ISessionState, action: SessionAction) {
function sessionReducer(_state: ISessionState, action: SessionAction) {
switch (action.type) {
case SessionActionTypes.SET_SESSION:
setState("session", "sessions", action.payload.id, action.payload);

View file

@ -9,9 +9,11 @@ const [state, setState] = createStore<IState>({
dialogsOpen: {
settingsOpen: false,
addCommunityOpen: false,
communitySettingsOpen: false,
},
},
auth: {
loggedIn: undefined,
session: undefined,
},
user: {
@ -33,6 +35,9 @@ const [state, setState] = createStore<IState>({
invite: {
invites: {},
},
message: {
message: undefined,
},
});
function dispatch(action: Action) {

View file

@ -6,6 +6,7 @@ import { IChannelState } from "./channel";
import { IRoleState } from "./role";
import { ISessionState } from "./session";
import { IInviteState } from "./invite";
import { IMessageState } from "./message";
interface IState {
app: IAppState;
@ -16,6 +17,7 @@ interface IState {
role: IRoleState;
session: ISessionState;
invite: IInviteState;
message: IMessageState;
}
export { type IState };

View file

@ -8,7 +8,7 @@ import { setState } from "../state";
import { UserActionTypes, UserAction } from "./actions";
import { IUserState } from "./types";
function userReducer(state: IUserState, action: UserAction) {
function userReducer(_state: IUserState, action: UserAction) {
switch (action.type) {
case UserActionTypes.SET_USER:
setState("user", "users", action.payload.id, action.payload);

View file

@ -8,8 +8,12 @@ import { AuthActionTypes } from "../../store/auth";
import { HomeView } from "../HomeView";
import { CommunityView } from "../CommunityView";
import { ModalView } from "../ModalView";
import { useNavigate } from "@solidjs/router";
import { connectWs } from "../../services/websocket";
const AppView: Component = () => {
const navigate = useNavigate();
const MainView: Component = () => {
onMount(() => {
dispatch({
type: AuthActionTypes.FETCH_REFRESH_START,
@ -31,6 +35,14 @@ const MainView: Component = () => {
}
});
createEffect(() => {
if (state.auth.loggedIn === false) {
navigate("/login");
} else {
connectWs();
}
});
return (
<div class="flex flex-row h-screen">
<ModalView />
@ -50,4 +62,4 @@ const MainView: Component = () => {
);
};
export { MainView };
export { AppView };

View file

@ -0,0 +1 @@
export * from "./AppView";

View file

@ -4,6 +4,7 @@ import { CommunityActionTypes, ICommunity } from "../../store/community";
import { ChannelActionTypes } from "../../store/channel";
import { Channel } from "../../components/Channel";
import { CommunityBar } from "../../components/CommunityBar";
import { AppActionTypes } from "../../store/app";
const ChannelView: Component = () => {
const channelIds = createMemo(() => {
@ -34,6 +35,13 @@ const ChannelView: Component = () => {
}
});
const onCommunitySettingsClick = () => {
dispatch({
type: AppActionTypes.SET_ADD_COMMUNITY_SETTINGS_OPEN,
payload: true,
});
};
const onChannelClick = (id: string) => {
dispatch({
type: ChannelActionTypes.FETCH_CHANNEL_START,
@ -70,6 +78,7 @@ const ChannelView: Component = () => {
avatar={
"https://img.daisyui.com/images/profile/demo/yellingcat@192.webp"
}
onSettingsClick={onCommunitySettingsClick}
/>
<ul class="h-full list flex flex-col p-2 gap-1 pt-18 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-500 scrollbar-track-gray-800">
{channelIds().map(mapChannel)}

View file

@ -1,189 +1,77 @@
import { createMemo, onMount, type Component } from "solid-js";
import { createEffect, createMemo, onMount, type Component } from "solid-js";
import { ChannelBar } from "../../components/ChannelBar";
import { MessageBar } from "../../components/MessageBar";
import { Message } from "../../components/Message";
import { dispatch, state } from "../../store/state";
import { UserActionTypes } from "../../store/user";
import { IChannel } from "../../store/channel";
import { ChannelActionTypes, IChannel } from "../../store/channel";
import { MessageActionTypes } from "../../store/message";
const ChatView: Component = () => {
const testMessages = [
{
messageId: "432432",
message: "Hello this is a test message",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432433",
message: "Hi hi",
userId: "12122",
username: "TestUser",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
{
messageId: "432434",
message: "Nooooo",
userId: "12121",
username: "Aslan",
avatar: "https://img.daisyui.com/images/profile/demo/yellingcat@192.webp",
},
];
let scrollRef: HTMLUListElement | undefined;
let lastScrollTop = 0;
let autoScroll = true;
const scrollToBottom = () => {
if (!scrollRef || !autoScroll) {
return;
}
scrollRef.scrollTop = scrollRef.scrollHeight;
};
const handleScroll = () => {
if (!scrollRef) {
return;
}
if (scrollRef.scrollTop < lastScrollTop) {
autoScroll = false;
}
if (
scrollRef.scrollTop + scrollRef.clientHeight >=
scrollRef.scrollHeight
) {
autoScroll = true;
}
lastScrollTop = scrollRef.scrollTop;
};
const channelInfo = createMemo<IChannel | undefined>(() => {
const activeChannelId = state.channel.active;
return state.channel.channels[activeChannelId ?? 0];
if (!activeChannelId) {
return undefined;
}
autoScroll = true;
dispatch({
type: ChannelActionTypes.FETCH_CHANNEL_MESSAGES_START,
payload: activeChannelId,
});
return state.channel.channels[activeChannelId];
});
const messages = createMemo(() => {
const channelId = channelInfo()?.id;
if (!channelId) {
return [];
}
const channel = state.channel.channels[channelId];
if (!channel) {
return [];
}
Object.keys(channel.messages ?? []).length;
setTimeout(scrollToBottom, 20);
setTimeout(scrollToBottom, 100);
return channel.messages ?? [];
});
let scrollRef: HTMLUListElement | undefined;
onMount(() => {
if (scrollRef) {
scrollRef.scrollTop = scrollRef.scrollHeight;
@ -197,6 +85,50 @@ const ChatView: Component = () => {
});
};
const onChangeMessageText = (text: string) => {
const channel = channelInfo();
if (!channel?.id) {
return;
}
dispatch({
type: ChannelActionTypes.SET_TEXT,
payload: {
id: channel.id,
text: text,
},
});
};
const onMessageSend = () => {
autoScroll = true;
const channel = channelInfo();
if (!channel?.id) {
return;
}
const text = channel.text;
if (!text || text.trim().length < 1) {
return;
}
dispatch({
type: ChannelActionTypes.SET_TEXT,
payload: {
id: channel.id,
text: "",
},
});
dispatch({
type: MessageActionTypes.CREATE_MESSAGE_START,
payload: {
channelId: channel.id,
text: text,
},
});
};
return (
<div class="bg-stone-800 flex-1 z-0 relative">
<div class="h-full">
@ -207,20 +139,29 @@ const ChatView: Component = () => {
/>
<ul
ref={scrollRef}
onScrollEnd={handleScroll}
class="h-full list flex flex-col p-2 pt-18 pb-24 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-500 scrollbar-track-gray-800"
>
{testMessages.map((msg) => (
{Object.entries(messages()).map(([_key, message]) => (
<Message
messageId={msg.messageId}
message={msg.message}
userId={msg.userId}
username={msg.username}
avatar={msg.avatar}
messageId={message.id}
message={message.text}
userId={message.ownerId}
username={
state.user.users[message.ownerId].username ?? ""
}
avatar={
"https://img.daisyui.com/images/profile/demo/yellingcat@192.webp"
}
onProfileClick={onProfileClick}
/>
))}
</ul>
<MessageBar />
<MessageBar
text={channelInfo()?.text ?? ""}
onChangeText={onChangeMessageText}
onSend={onMessageSend}
/>
</div>
</div>
);

View file

@ -1,4 +1,4 @@
import { createEffect, createSignal, type Component } from "solid-js";
import { createEffect, createSignal, onMount, type Component } from "solid-js";
import { dispatch, state } from "../../store/state";
import { AuthActionTypes } from "../../store/auth";
import { useNavigate } from "@solidjs/router";
@ -9,6 +9,12 @@ const LoginView: Component = () => {
const navigate = useNavigate();
onMount(() => {
dispatch({
type: AuthActionTypes.FETCH_REFRESH_START,
});
});
const onLogin = () => {
dispatch({
type: AuthActionTypes.FETCH_LOGIN_START,
@ -20,8 +26,8 @@ const LoginView: Component = () => {
};
createEffect(() => {
if (state.auth.session?.id) {
navigate("/");
if (state.auth.loggedIn) {
navigate("/app");
}
});

View file

@ -1 +0,0 @@
export * from "./MainView";

View file

@ -2,11 +2,13 @@ import { createEffect, type Component } from "solid-js";
import { dispatch, state } from "../../store/state";
import SettingsModal from "../../components/SettingsModal/SettingsModal";
import CommunityModal from "../../components/CommunityModal/CommunityModal";
import CommunitySettingsModal from "../../components/CommunitySettingsModal/CommunitySettingsModal";
import { AppActionTypes } from "../../store/app";
const ModalView: Component = () => {
let settingsModal: HTMLDialogElement;
let communityModal: HTMLDialogElement;
let communitySettingsModal: HTMLDialogElement;
createEffect(() => {
if (state.app.dialogsOpen.settingsOpen) {
@ -24,6 +26,14 @@ const ModalView: Component = () => {
}
});
createEffect(() => {
if (state.app.dialogsOpen.communitySettingsOpen) {
communitySettingsModal.showModal();
} else {
communitySettingsModal.close();
}
});
const onCloseSettings = () => {
dispatch({
type: AppActionTypes.SET_SETTINGS_OPEN,
@ -38,6 +48,13 @@ const ModalView: Component = () => {
});
};
const onCloseCommunitySettings = () => {
dispatch({
type: AppActionTypes.SET_ADD_COMMUNITY_SETTINGS_OPEN,
payload: false,
});
};
return (
<>
<SettingsModal
@ -48,6 +65,10 @@ const ModalView: Component = () => {
dialogRef={(element) => (communityModal = element)}
onClose={onCloseCommunity}
/>
<CommunitySettingsModal
dialogRef={(element) => (communitySettingsModal = element)}
onClose={onCloseCommunitySettings}
/>
</>
);
};

View file

@ -1,6 +1,27 @@
import type { Component } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { createSignal, createEffect, type Component, onMount } from "solid-js";
import { dispatch, state } from "../../store/state";
import { AuthActionTypes } from "../../store/auth";
const RegisterView: Component = () => {
const [getUsername, setUsername] = createSignal("");
const [getEmail, setEmail] = createSignal("");
const [getPassword, setPassword] = createSignal("");
const navigate = useNavigate();
onMount(() => {
dispatch({
type: AuthActionTypes.FETCH_REFRESH_START,
});
});
createEffect(() => {
if (state.auth.loggedIn) {
navigate("/app");
}
});
return <div></div>;
};

View file

@ -1,7 +0,0 @@
import type { Component } from "solid-js";
const SettingsView: Component = () => {
return <div></div>;
};
export { SettingsView };

View file

@ -1 +0,0 @@
export * from "./SettingsView";

View file

@ -0,0 +1,7 @@
import { type Component } from "solid-js";
const StartView: Component = () => {
return <div></div>;
};
export { StartView };

View file

@ -0,0 +1 @@
export * from "./StartView";